@jet-w/astro-blog 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/chunk-FXPGR372.js +0 -0
- package/dist/chunk-GYLSY3OJ.js +173 -0
- package/dist/config/index.d.ts +166 -0
- package/dist/config/index.js +38 -0
- package/dist/index.d.ts +34 -0
- package/dist/index.js +59 -0
- package/dist/types/index.d.ts +75 -0
- package/dist/types/index.js +1 -0
- package/package.json +84 -0
- package/src/components/EChartsCard.vue +118 -0
- package/src/components/Mermaid.vue +73 -0
- package/src/components/about/ContentCard.astro +27 -0
- package/src/components/about/IconCard.astro +77 -0
- package/src/components/about/SocialLinks.astro +54 -0
- package/src/components/about/TagCard.astro +65 -0
- package/src/components/about/TagGroup.astro +33 -0
- package/src/components/about/TimelineCard.astro +52 -0
- package/src/components/blog/FloatingToc.vue +198 -0
- package/src/components/blog/Hero.astro +147 -0
- package/src/components/blog/NavigationTabs.vue +245 -0
- package/src/components/blog/PostCard.astro +161 -0
- package/src/components/blog/PostNavigation.astro +106 -0
- package/src/components/blog/RelatedPosts.astro +175 -0
- package/src/components/blog/TableOfContents.astro +153 -0
- package/src/components/blog/TagCloud.astro +91 -0
- package/src/components/home/FeaturedPostsSection.astro +54 -0
- package/src/components/home/QuickNavSection.astro +81 -0
- package/src/components/home/RecentPostsSection.astro +52 -0
- package/src/components/home/StatsSection.astro +44 -0
- package/src/components/layout/Footer.astro +103 -0
- package/src/components/layout/Header.astro +68 -0
- package/src/components/layout/Sidebar.astro +594 -0
- package/src/components/media/Bilibili.astro +114 -0
- package/src/components/media/Slides.astro +313 -0
- package/src/components/media/Video.astro +111 -0
- package/src/components/media/VideoPlayer.astro +89 -0
- package/src/components/media/YouTube.astro +92 -0
- package/src/components/pte/StudyCalendar.vue +1348 -0
- package/src/components/ui/Icon.astro +187 -0
- package/src/components/ui/MobileMenu.vue +201 -0
- package/src/components/ui/Pagination.astro +143 -0
- package/src/components/ui/SearchBox.vue +179 -0
- package/src/components/ui/SearchInterface.vue +409 -0
- package/src/components/ui/SidebarToggle.vue +57 -0
- package/src/components/ui/ThemeToggle.vue +90 -0
- package/src/layouts/AboutLayout.astro +18 -0
- package/src/layouts/BaseLayout.astro +362 -0
- package/src/layouts/PageLayout.astro +217 -0
- package/src/layouts/SlidesLayout.astro +320 -0
- package/src/plugins/rehype-clean-containers.mjs +24 -0
- package/src/plugins/rehype-relative-links.mjs +43 -0
- package/src/plugins/rehype-tabs.mjs +116 -0
- package/src/plugins/remark-containers.mjs +407 -0
- package/src/plugins/remark-mermaid.mjs +46 -0
- package/src/styles/global.css +870 -0
- package/src/styles/slides.css +220 -0
- package/src/utils/sidebar.ts +492 -0
- package/templates/default/astro.config.mjs +51 -0
- package/templates/default/content/pages/about.mdx +93 -0
- package/templates/default/content/pages/index.mdx +20 -0
- package/templates/default/content/posts/blog_docs/01-quick-start.md +162 -0
- package/templates/default/content/posts/blog_docs/02-frontmatter.md +277 -0
- package/templates/default/content/posts/blog_docs/03-markdown-basic.md +350 -0
- package/templates/default/content/posts/blog_docs/04-containers.md +331 -0
- package/templates/default/content/posts/blog_docs/05-code-blocks.md +388 -0
- package/templates/default/content/posts/blog_docs/06-mermaid.md +431 -0
- package/templates/default/content/posts/blog_docs/07-video.md +243 -0
- package/templates/default/content/posts/blog_docs/08-latex.md +382 -0
- package/templates/default/content/posts/blog_docs/09-icons.md +326 -0
- package/templates/default/content/posts/blog_docs/10-sidebar.md +445 -0
- package/templates/default/content/posts/blog_docs/11-config.md +334 -0
- package/templates/default/content/posts/blog_docs/12-slides.mdx +552 -0
- package/templates/default/content/posts/blog_docs/README.md +151 -0
- package/templates/default/content/slides/demo.md +146 -0
- package/templates/default/content/slides/docs/basic-demo.md +35 -0
- package/templates/default/content/slides/docs/code-demo.md +62 -0
- package/templates/default/content/slides/docs/echarts-demo.md +139 -0
- package/templates/default/content/slides/docs/fragment-demo.md +35 -0
- package/templates/default/content/slides/docs/math-demo.md +48 -0
- package/templates/default/content/slides/docs/mermaid-demo.md +105 -0
- package/templates/default/content/slides/docs/theme-demo.md +38 -0
- package/templates/default/content/slides/docs/vertical-demo.md +50 -0
- package/templates/default/package.json +31 -0
- package/templates/default/public/favicon-bak.svg +4 -0
- package/templates/default/public/images/avatar.jpg +0 -0
- package/templates/default/public/images/avatar.svg +142 -0
- package/templates/default/public/js/mermaid-container.js +402 -0
- package/templates/default/public/js/mermaid-init.js +131 -0
- package/templates/default/public/js/mermaid-render.js +98 -0
- package/templates/default/public/js/mermaid-simple.js +95 -0
- package/templates/default/public/js/tabs-init.js +86 -0
- package/templates/default/public/media/individual_portfolio/INDIVIDUAL PORTFOLIO.png +0 -0
- package/templates/default/public/slides/plugin/highlight/highlight.js +5 -0
- package/templates/default/public/slides/plugin/highlight/monokai.css +71 -0
- package/templates/default/public/slides/plugin/markdown/markdown.js +7 -0
- package/templates/default/public/slides/plugin/math/math.js +1 -0
- package/templates/default/public/slides/plugin/notes/notes.js +1 -0
- package/templates/default/public/slides/reveal.css +9 -0
- package/templates/default/public/slides/reveal.js +9 -0
- package/templates/default/public/slides/theme/beige.css +366 -0
- package/templates/default/public/slides/theme/black-contrast.css +362 -0
- package/templates/default/public/slides/theme/black.css +359 -0
- package/templates/default/public/slides/theme/blood.css +392 -0
- package/templates/default/public/slides/theme/dracula.css +385 -0
- package/templates/default/public/slides/theme/league.css +368 -0
- package/templates/default/public/slides/theme/moon.css +362 -0
- package/templates/default/public/slides/theme/night.css +360 -0
- package/templates/default/public/slides/theme/serif.css +363 -0
- package/templates/default/public/slides/theme/simple.css +362 -0
- package/templates/default/public/slides/theme/sky.css +370 -0
- package/templates/default/public/slides/theme/solarized.css +363 -0
- package/templates/default/public/slides/theme/white-contrast.css +362 -0
- package/templates/default/public/slides/theme/white.css +359 -0
- package/templates/default/public/slides/theme/white_contrast_compact_verbatim_headers.css +360 -0
- package/templates/default/public/test-complete.html +43 -0
- package/templates/default/public/test-mermaid.html +124 -0
- package/templates/default/src/config/index.ts +114 -0
- package/templates/default/src/content.config.ts +96 -0
- package/templates/default/src/pages/[...slug].astro +27 -0
- package/templates/default/src/pages/archives/[year]/[month]/page/[page].astro +176 -0
- package/templates/default/src/pages/archives/[year]/[month].astro +158 -0
- package/templates/default/src/pages/archives/index.astro +210 -0
- package/templates/default/src/pages/categories/[category]/page/[page].astro +218 -0
- package/templates/default/src/pages/categories/[category].astro +198 -0
- package/templates/default/src/pages/categories/index.astro +190 -0
- package/templates/default/src/pages/container-test.astro +79 -0
- package/templates/default/src/pages/mermaid-direct.html +78 -0
- package/templates/default/src/pages/posts/[...slug].astro +335 -0
- package/templates/default/src/pages/posts/index.astro +541 -0
- package/templates/default/src/pages/posts/page/[page].astro +146 -0
- package/templates/default/src/pages/rss.xml.ts +28 -0
- package/templates/default/src/pages/search-index.json.ts +21 -0
- package/templates/default/src/pages/search.astro +50 -0
- package/templates/default/src/pages/slides/[...slug].astro +54 -0
- package/templates/default/src/pages/slides/index.astro +135 -0
- package/templates/default/src/pages/tags/[tag]/page/[page].astro +211 -0
- package/templates/default/src/pages/tags/[tag].astro +191 -0
- package/templates/default/src/pages/tags/index.astro +167 -0
- package/templates/default/tailwind.config.mjs +78 -0
- package/templates/default/tsconfig.json +9 -0
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="relative">
|
|
3
|
+
<div class="relative">
|
|
4
|
+
<input
|
|
5
|
+
v-model="searchQuery"
|
|
6
|
+
@input="handleInput"
|
|
7
|
+
@focus="handleFocus"
|
|
8
|
+
@blur="handleBlur"
|
|
9
|
+
type="text"
|
|
10
|
+
placeholder="搜索文章..."
|
|
11
|
+
class="w-64 pl-10 pr-4 py-2 text-sm bg-slate-100 dark:bg-slate-800 border border-slate-300 dark:border-slate-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent transition-colors"
|
|
12
|
+
/>
|
|
13
|
+
<div class="absolute inset-y-0 left-0 pl-3 flex items-center pointer-events-none">
|
|
14
|
+
<svg class="w-4 h-4 text-slate-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
15
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
|
16
|
+
</svg>
|
|
17
|
+
</div>
|
|
18
|
+
</div>
|
|
19
|
+
|
|
20
|
+
<!-- 搜索结果 -->
|
|
21
|
+
<transition name="fade">
|
|
22
|
+
<div
|
|
23
|
+
v-if="showResults && (searchResults.length > 0 || searchQuery.length > 0)"
|
|
24
|
+
class="absolute top-full left-0 right-0 mt-2 bg-white dark:bg-slate-800 border border-slate-200 dark:border-slate-700 rounded-lg shadow-lg z-50 max-h-96 overflow-y-auto"
|
|
25
|
+
>
|
|
26
|
+
<div v-if="searchResults.length === 0 && searchQuery.length > 0" class="p-4 text-center text-slate-500 dark:text-slate-400">
|
|
27
|
+
没有找到相关文章
|
|
28
|
+
</div>
|
|
29
|
+
<div v-else class="py-2">
|
|
30
|
+
<a
|
|
31
|
+
v-for="result in searchResults"
|
|
32
|
+
:key="result.url"
|
|
33
|
+
:href="result.url"
|
|
34
|
+
class="block px-4 py-3 hover:bg-slate-50 dark:hover:bg-slate-700 transition-colors border-b border-slate-100 dark:border-slate-700 last:border-b-0"
|
|
35
|
+
@click="handleResultClick"
|
|
36
|
+
>
|
|
37
|
+
<h4 class="text-sm font-medium text-slate-900 dark:text-slate-100 mb-1" v-html="highlightText(result.title)"></h4>
|
|
38
|
+
<p class="text-xs text-slate-600 dark:text-slate-400 line-clamp-2" v-html="highlightText(result.description)"></p>
|
|
39
|
+
<div class="flex flex-wrap gap-1 mt-2">
|
|
40
|
+
<span
|
|
41
|
+
v-for="tag in result.tags.slice(0, 3)"
|
|
42
|
+
:key="tag"
|
|
43
|
+
class="text-xs px-2 py-1 bg-primary-100 dark:bg-primary-900/30 text-primary-700 dark:text-primary-300 rounded-full"
|
|
44
|
+
>
|
|
45
|
+
{{ tag }}
|
|
46
|
+
</span>
|
|
47
|
+
</div>
|
|
48
|
+
</a>
|
|
49
|
+
</div>
|
|
50
|
+
</div>
|
|
51
|
+
</transition>
|
|
52
|
+
</div>
|
|
53
|
+
</template>
|
|
54
|
+
|
|
55
|
+
<script setup lang="ts">
|
|
56
|
+
import { ref, onMounted } from 'vue'
|
|
57
|
+
|
|
58
|
+
interface SearchResult {
|
|
59
|
+
title: string
|
|
60
|
+
description: string
|
|
61
|
+
url: string
|
|
62
|
+
content: string
|
|
63
|
+
tags: string[]
|
|
64
|
+
categories: string[]
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const searchQuery = ref('')
|
|
68
|
+
const searchResults = ref<SearchResult[]>([])
|
|
69
|
+
const showResults = ref(false)
|
|
70
|
+
const searchIndex = ref<SearchResult[]>([])
|
|
71
|
+
const isLoading = ref(false)
|
|
72
|
+
const isLoaded = ref(false)
|
|
73
|
+
|
|
74
|
+
// 加载搜索索引
|
|
75
|
+
const loadSearchIndex = async () => {
|
|
76
|
+
if (isLoaded.value || isLoading.value) return
|
|
77
|
+
|
|
78
|
+
isLoading.value = true
|
|
79
|
+
try {
|
|
80
|
+
const response = await fetch('/search-index.json')
|
|
81
|
+
if (response.ok) {
|
|
82
|
+
searchIndex.value = await response.json()
|
|
83
|
+
isLoaded.value = true
|
|
84
|
+
}
|
|
85
|
+
} catch (error) {
|
|
86
|
+
console.error('Failed to load search index:', error)
|
|
87
|
+
} finally {
|
|
88
|
+
isLoading.value = false
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const handleInput = async () => {
|
|
93
|
+
// 首次输入时加载索引
|
|
94
|
+
if (!isLoaded.value) {
|
|
95
|
+
await loadSearchIndex()
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
if (searchQuery.value.length === 0) {
|
|
99
|
+
searchResults.value = []
|
|
100
|
+
return
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// 简单的文本搜索实现
|
|
104
|
+
const query = searchQuery.value.toLowerCase()
|
|
105
|
+
searchResults.value = searchIndex.value.filter(item =>
|
|
106
|
+
item.title.toLowerCase().includes(query) ||
|
|
107
|
+
item.description.toLowerCase().includes(query) ||
|
|
108
|
+
item.content.toLowerCase().includes(query) ||
|
|
109
|
+
item.tags.some(tag => tag.toLowerCase().includes(query))
|
|
110
|
+
).slice(0, 8) // 限制结果数量
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const handleFocus = async () => {
|
|
114
|
+
showResults.value = true
|
|
115
|
+
// 聚焦时预加载索引
|
|
116
|
+
if (!isLoaded.value) {
|
|
117
|
+
await loadSearchIndex()
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const handleBlur = () => {
|
|
122
|
+
// 延迟隐藏结果,允许点击结果
|
|
123
|
+
setTimeout(() => {
|
|
124
|
+
showResults.value = false
|
|
125
|
+
}, 200)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
const handleResultClick = () => {
|
|
129
|
+
showResults.value = false
|
|
130
|
+
searchQuery.value = ''
|
|
131
|
+
searchResults.value = []
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const highlightText = (text: string) => {
|
|
135
|
+
if (!searchQuery.value || !text) return text || ''
|
|
136
|
+
|
|
137
|
+
try {
|
|
138
|
+
// 转义特殊字符
|
|
139
|
+
const escapedQuery = searchQuery.value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
140
|
+
const regex = new RegExp(`(${escapedQuery})`, 'gi')
|
|
141
|
+
return text.replace(regex, '<mark class="bg-yellow-200 dark:bg-yellow-800 px-1 rounded">$1</mark>')
|
|
142
|
+
} catch {
|
|
143
|
+
return text
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// 键盘快捷键支持
|
|
148
|
+
onMounted(() => {
|
|
149
|
+
// Ctrl/Cmd + K 打开搜索
|
|
150
|
+
document.addEventListener('keydown', (e) => {
|
|
151
|
+
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
|
|
152
|
+
e.preventDefault()
|
|
153
|
+
const input = document.querySelector('input[placeholder="搜索文章..."]') as HTMLInputElement
|
|
154
|
+
if (input) {
|
|
155
|
+
input.focus()
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
})
|
|
159
|
+
})
|
|
160
|
+
</script>
|
|
161
|
+
|
|
162
|
+
<style scoped>
|
|
163
|
+
.fade-enter-active,
|
|
164
|
+
.fade-leave-active {
|
|
165
|
+
transition: opacity 0.2s ease;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
.fade-enter-from,
|
|
169
|
+
.fade-leave-to {
|
|
170
|
+
opacity: 0;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
.line-clamp-2 {
|
|
174
|
+
display: -webkit-box;
|
|
175
|
+
-webkit-line-clamp: 2;
|
|
176
|
+
-webkit-box-orient: vertical;
|
|
177
|
+
overflow: hidden;
|
|
178
|
+
}
|
|
179
|
+
</style>
|
|
@@ -0,0 +1,409 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<div class="space-y-8">
|
|
3
|
+
<!-- 搜索框 -->
|
|
4
|
+
<div class="relative">
|
|
5
|
+
<div class="relative">
|
|
6
|
+
<input
|
|
7
|
+
v-model="searchQuery"
|
|
8
|
+
@input="handleSearch"
|
|
9
|
+
type="text"
|
|
10
|
+
placeholder="搜索文章标题、内容、标签..."
|
|
11
|
+
class="w-full pl-12 pr-4 py-4 text-lg bg-white dark:bg-slate-800 border-2 border-slate-300 dark:border-slate-600 rounded-xl focus:outline-none focus:ring-2 focus:ring-primary-500 focus:border-transparent transition-colors shadow-sm"
|
|
12
|
+
autofocus
|
|
13
|
+
/>
|
|
14
|
+
<div class="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none">
|
|
15
|
+
<svg class="w-6 h-6 text-slate-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
16
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
|
17
|
+
</svg>
|
|
18
|
+
</div>
|
|
19
|
+
<div v-if="searchQuery" class="absolute inset-y-0 right-0 pr-4 flex items-center">
|
|
20
|
+
<button
|
|
21
|
+
@click="clearSearch"
|
|
22
|
+
class="text-slate-400 hover:text-slate-600 dark:hover:text-slate-300 transition-colors"
|
|
23
|
+
>
|
|
24
|
+
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
25
|
+
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
|
|
26
|
+
</svg>
|
|
27
|
+
</button>
|
|
28
|
+
</div>
|
|
29
|
+
</div>
|
|
30
|
+
</div>
|
|
31
|
+
|
|
32
|
+
<!-- 筛选器 -->
|
|
33
|
+
<div class="flex flex-wrap gap-4">
|
|
34
|
+
<!-- 标签筛选 -->
|
|
35
|
+
<div class="flex items-center space-x-2">
|
|
36
|
+
<label class="text-sm font-medium text-slate-700 dark:text-slate-300">标签:</label>
|
|
37
|
+
<select
|
|
38
|
+
v-model="selectedTag"
|
|
39
|
+
@change="handleSearch"
|
|
40
|
+
class="text-sm px-3 py-1 bg-white dark:bg-slate-800 border border-slate-300 dark:border-slate-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
|
|
41
|
+
>
|
|
42
|
+
<option value="">全部</option>
|
|
43
|
+
<option v-for="tag in allTags" :key="tag" :value="tag">{{ tag }}</option>
|
|
44
|
+
</select>
|
|
45
|
+
</div>
|
|
46
|
+
|
|
47
|
+
<!-- 分类筛选 -->
|
|
48
|
+
<div class="flex items-center space-x-2">
|
|
49
|
+
<label class="text-sm font-medium text-slate-700 dark:text-slate-300">分类:</label>
|
|
50
|
+
<select
|
|
51
|
+
v-model="selectedCategory"
|
|
52
|
+
@change="handleSearch"
|
|
53
|
+
class="text-sm px-3 py-1 bg-white dark:bg-slate-800 border border-slate-300 dark:border-slate-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
|
|
54
|
+
>
|
|
55
|
+
<option value="">全部</option>
|
|
56
|
+
<option v-for="category in allCategories" :key="category" :value="category">{{ category }}</option>
|
|
57
|
+
</select>
|
|
58
|
+
</div>
|
|
59
|
+
|
|
60
|
+
<!-- 排序 -->
|
|
61
|
+
<div class="flex items-center space-x-2">
|
|
62
|
+
<label class="text-sm font-medium text-slate-700 dark:text-slate-300">排序:</label>
|
|
63
|
+
<select
|
|
64
|
+
v-model="sortBy"
|
|
65
|
+
@change="handleSearch"
|
|
66
|
+
class="text-sm px-3 py-1 bg-white dark:bg-slate-800 border border-slate-300 dark:border-slate-600 rounded-lg focus:outline-none focus:ring-2 focus:ring-primary-500"
|
|
67
|
+
>
|
|
68
|
+
<option value="relevance">相关性</option>
|
|
69
|
+
<option value="date">发布时间</option>
|
|
70
|
+
<option value="title">标题</option>
|
|
71
|
+
</select>
|
|
72
|
+
</div>
|
|
73
|
+
</div>
|
|
74
|
+
|
|
75
|
+
<!-- 搜索状态 -->
|
|
76
|
+
<div class="flex items-center justify-between text-sm text-slate-600 dark:text-slate-400">
|
|
77
|
+
<div>
|
|
78
|
+
<span v-if="isSearching">正在搜索...</span>
|
|
79
|
+
<span v-else-if="searchQuery">
|
|
80
|
+
找到 {{ searchResults.length }} 个结果
|
|
81
|
+
<span v-if="searchQuery">包含 "{{ searchQuery }}"</span>
|
|
82
|
+
</span>
|
|
83
|
+
<span v-else>输入关键词开始搜索</span>
|
|
84
|
+
</div>
|
|
85
|
+
<div v-if="searchTime">
|
|
86
|
+
搜索用时: {{ searchTime }}ms
|
|
87
|
+
</div>
|
|
88
|
+
</div>
|
|
89
|
+
|
|
90
|
+
<!-- 搜索结果 -->
|
|
91
|
+
<div v-if="searchResults.length > 0" class="space-y-6">
|
|
92
|
+
<article
|
|
93
|
+
v-for="result in paginatedResults"
|
|
94
|
+
:key="result.url"
|
|
95
|
+
class="card hover:shadow-lg transition-shadow duration-200"
|
|
96
|
+
>
|
|
97
|
+
<a :href="result.url" class="block group">
|
|
98
|
+
<div class="flex flex-col lg:flex-row lg:items-center gap-4">
|
|
99
|
+
<!-- 文章信息 -->
|
|
100
|
+
<div class="flex-1">
|
|
101
|
+
<h3 class="text-xl font-semibold text-slate-900 dark:text-slate-100 group-hover:text-primary-500 transition-colors mb-2" v-html="highlightText(result.title)"></h3>
|
|
102
|
+
|
|
103
|
+
<p class="text-slate-600 dark:text-slate-400 mb-3 line-clamp-2" v-html="highlightText(result.description)"></p>
|
|
104
|
+
|
|
105
|
+
<div class="flex flex-wrap items-center gap-4 text-sm text-slate-500 dark:text-slate-400">
|
|
106
|
+
<time :datetime="result.date">{{ formatDate(result.date) }}</time>
|
|
107
|
+
<span v-if="result.readingTime">{{ result.readingTime }}分钟阅读</span>
|
|
108
|
+
<div class="flex flex-wrap gap-1">
|
|
109
|
+
<span
|
|
110
|
+
v-for="tag in result.tags.slice(0, 3)"
|
|
111
|
+
:key="tag"
|
|
112
|
+
class="px-2 py-1 bg-primary-100 dark:bg-primary-900/30 text-primary-700 dark:text-primary-300 rounded-full text-xs"
|
|
113
|
+
>
|
|
114
|
+
{{ tag }}
|
|
115
|
+
</span>
|
|
116
|
+
</div>
|
|
117
|
+
</div>
|
|
118
|
+
</div>
|
|
119
|
+
|
|
120
|
+
<!-- 相关度评分 -->
|
|
121
|
+
<div class="lg:w-20 text-center">
|
|
122
|
+
<div class="text-xs text-slate-500 dark:text-slate-400 mb-1">相关度</div>
|
|
123
|
+
<div class="flex items-center justify-center">
|
|
124
|
+
<div class="w-12 h-2 bg-slate-200 dark:bg-slate-700 rounded-full overflow-hidden">
|
|
125
|
+
<div
|
|
126
|
+
class="h-full bg-primary-500 rounded-full transition-all duration-300"
|
|
127
|
+
:style="{ width: `${Math.round(result.score * 100)}%` }"
|
|
128
|
+
></div>
|
|
129
|
+
</div>
|
|
130
|
+
</div>
|
|
131
|
+
</div>
|
|
132
|
+
</div>
|
|
133
|
+
</a>
|
|
134
|
+
</article>
|
|
135
|
+
</div>
|
|
136
|
+
|
|
137
|
+
<!-- 空状态 -->
|
|
138
|
+
<div v-else-if="searchQuery && !isSearching" class="text-center py-16">
|
|
139
|
+
<div class="text-6xl mb-4">🔍</div>
|
|
140
|
+
<h3 class="text-xl font-semibold text-slate-900 dark:text-slate-100 mb-2">
|
|
141
|
+
没有找到相关结果
|
|
142
|
+
</h3>
|
|
143
|
+
<p class="text-slate-600 dark:text-slate-400 mb-6">
|
|
144
|
+
尝试使用不同的关键词或调整筛选条件
|
|
145
|
+
</p>
|
|
146
|
+
<button
|
|
147
|
+
@click="clearSearch"
|
|
148
|
+
class="btn-secondary"
|
|
149
|
+
>
|
|
150
|
+
清除搜索
|
|
151
|
+
</button>
|
|
152
|
+
</div>
|
|
153
|
+
|
|
154
|
+
<!-- 分页 -->
|
|
155
|
+
<div v-if="totalPages > 1" class="flex justify-center">
|
|
156
|
+
<nav class="flex items-center space-x-2">
|
|
157
|
+
<button
|
|
158
|
+
@click="currentPage = Math.max(1, currentPage - 1)"
|
|
159
|
+
:disabled="currentPage === 1"
|
|
160
|
+
class="px-3 py-2 text-sm font-medium rounded-lg border transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
|
161
|
+
:class="currentPage === 1
|
|
162
|
+
? 'bg-slate-100 dark:bg-slate-800 text-slate-400 border-slate-200 dark:border-slate-700'
|
|
163
|
+
: 'bg-white dark:bg-slate-800 text-slate-700 dark:text-slate-300 border-slate-300 dark:border-slate-600 hover:bg-slate-50 dark:hover:bg-slate-700'"
|
|
164
|
+
>
|
|
165
|
+
上一页
|
|
166
|
+
</button>
|
|
167
|
+
|
|
168
|
+
<div class="flex items-center space-x-1">
|
|
169
|
+
<button
|
|
170
|
+
v-for="page in getPageNumbers()"
|
|
171
|
+
:key="page"
|
|
172
|
+
@click="currentPage = page"
|
|
173
|
+
class="px-3 py-2 text-sm font-medium rounded-lg transition-colors"
|
|
174
|
+
:class="page === currentPage
|
|
175
|
+
? 'bg-primary-500 text-white'
|
|
176
|
+
: 'bg-white dark:bg-slate-800 text-slate-700 dark:text-slate-300 border border-slate-300 dark:border-slate-600 hover:bg-slate-50 dark:hover:bg-slate-700'"
|
|
177
|
+
>
|
|
178
|
+
{{ page }}
|
|
179
|
+
</button>
|
|
180
|
+
</div>
|
|
181
|
+
|
|
182
|
+
<button
|
|
183
|
+
@click="currentPage = Math.min(totalPages, currentPage + 1)"
|
|
184
|
+
:disabled="currentPage === totalPages"
|
|
185
|
+
class="px-3 py-2 text-sm font-medium rounded-lg border transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
|
186
|
+
:class="currentPage === totalPages
|
|
187
|
+
? 'bg-slate-100 dark:bg-slate-800 text-slate-400 border-slate-200 dark:border-slate-700'
|
|
188
|
+
: 'bg-white dark:bg-slate-800 text-slate-700 dark:text-slate-300 border-slate-300 dark:border-slate-600 hover:bg-slate-50 dark:hover:bg-slate-700'"
|
|
189
|
+
>
|
|
190
|
+
下一页
|
|
191
|
+
</button>
|
|
192
|
+
</nav>
|
|
193
|
+
</div>
|
|
194
|
+
</div>
|
|
195
|
+
</template>
|
|
196
|
+
|
|
197
|
+
<script setup lang="ts">
|
|
198
|
+
import { ref, computed, onMounted } from 'vue'
|
|
199
|
+
import type { SearchResult } from '../../types'
|
|
200
|
+
|
|
201
|
+
const searchQuery = ref('')
|
|
202
|
+
const selectedTag = ref('')
|
|
203
|
+
const selectedCategory = ref('')
|
|
204
|
+
const sortBy = ref('relevance')
|
|
205
|
+
const searchResults = ref<(SearchResult & { score: number; date: string; readingTime?: number })[]>([])
|
|
206
|
+
const isSearching = ref(false)
|
|
207
|
+
const searchTime = ref(0)
|
|
208
|
+
const currentPage = ref(1)
|
|
209
|
+
const resultsPerPage = 10
|
|
210
|
+
|
|
211
|
+
// 模拟搜索数据
|
|
212
|
+
const mockSearchData = [
|
|
213
|
+
{
|
|
214
|
+
title: '欢迎来到Astro技术博客',
|
|
215
|
+
description: '这是使用Astro构建的现代化技术博客的第一篇文章,介绍博客的特性和使用方法。',
|
|
216
|
+
url: '/posts/welcome-to-astro',
|
|
217
|
+
content: 'Astro Vue TypeScript Tailwind CSS 博客 技术分享 现代化 静态站点生成',
|
|
218
|
+
tags: ['Astro', 'Blog', 'TypeScript', 'Tailwind CSS'],
|
|
219
|
+
categories: ['技术分享'],
|
|
220
|
+
date: '2025-01-08',
|
|
221
|
+
readingTime: 5
|
|
222
|
+
},
|
|
223
|
+
{
|
|
224
|
+
title: 'Astro vs Next.js:静态站点生成器的对比',
|
|
225
|
+
description: '深入比较Astro和Next.js在性能、开发体验和生态系统方面的差异。',
|
|
226
|
+
url: '/posts/astro-vs-nextjs',
|
|
227
|
+
content: 'Astro Next.js 对比 性能 SSG 静态站点生成 开发体验',
|
|
228
|
+
tags: ['Astro', 'Next.js', 'SSG'],
|
|
229
|
+
categories: ['技术比较'],
|
|
230
|
+
date: '2025-01-07',
|
|
231
|
+
readingTime: 8
|
|
232
|
+
},
|
|
233
|
+
{
|
|
234
|
+
title: 'Tailwind CSS最佳实践指南',
|
|
235
|
+
description: '总结使用Tailwind CSS开发时的最佳实践和常见问题解决方案。',
|
|
236
|
+
url: '/posts/tailwind-best-practices',
|
|
237
|
+
content: 'Tailwind CSS 最佳实践 响应式设计 工具类 优化',
|
|
238
|
+
tags: ['Tailwind CSS', 'CSS', '最佳实践'],
|
|
239
|
+
categories: ['前端开发'],
|
|
240
|
+
date: '2025-01-06',
|
|
241
|
+
readingTime: 6
|
|
242
|
+
}
|
|
243
|
+
]
|
|
244
|
+
|
|
245
|
+
const allTags = computed(() => {
|
|
246
|
+
const tags = new Set<string>()
|
|
247
|
+
mockSearchData.forEach(item => {
|
|
248
|
+
item.tags.forEach(tag => tags.add(tag))
|
|
249
|
+
})
|
|
250
|
+
return Array.from(tags).sort()
|
|
251
|
+
})
|
|
252
|
+
|
|
253
|
+
const allCategories = computed(() => {
|
|
254
|
+
const categories = new Set<string>()
|
|
255
|
+
mockSearchData.forEach(item => {
|
|
256
|
+
item.categories.forEach(category => categories.add(category))
|
|
257
|
+
})
|
|
258
|
+
return Array.from(categories).sort()
|
|
259
|
+
})
|
|
260
|
+
|
|
261
|
+
const totalPages = computed(() => {
|
|
262
|
+
return Math.ceil(searchResults.value.length / resultsPerPage)
|
|
263
|
+
})
|
|
264
|
+
|
|
265
|
+
const paginatedResults = computed(() => {
|
|
266
|
+
const start = (currentPage.value - 1) * resultsPerPage
|
|
267
|
+
const end = start + resultsPerPage
|
|
268
|
+
return searchResults.value.slice(start, end)
|
|
269
|
+
})
|
|
270
|
+
|
|
271
|
+
const performSearch = () => {
|
|
272
|
+
const startTime = performance.now()
|
|
273
|
+
isSearching.value = true
|
|
274
|
+
|
|
275
|
+
// 模拟搜索延迟
|
|
276
|
+
setTimeout(() => {
|
|
277
|
+
let results = [...mockSearchData]
|
|
278
|
+
|
|
279
|
+
// 文本搜索
|
|
280
|
+
if (searchQuery.value) {
|
|
281
|
+
const query = searchQuery.value.toLowerCase()
|
|
282
|
+
results = results.map(item => {
|
|
283
|
+
let score = 0
|
|
284
|
+
|
|
285
|
+
// 标题匹配 (权重最高)
|
|
286
|
+
if (item.title.toLowerCase().includes(query)) {
|
|
287
|
+
score += 10
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// 描述匹配
|
|
291
|
+
if (item.description.toLowerCase().includes(query)) {
|
|
292
|
+
score += 5
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
// 内容匹配
|
|
296
|
+
if (item.content.toLowerCase().includes(query)) {
|
|
297
|
+
score += 3
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
// 标签匹配
|
|
301
|
+
if (item.tags.some(tag => tag.toLowerCase().includes(query))) {
|
|
302
|
+
score += 7
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// 分类匹配
|
|
306
|
+
if (item.categories.some(category => category.toLowerCase().includes(query))) {
|
|
307
|
+
score += 6
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
return { ...item, score: Math.min(score / 10, 1) }
|
|
311
|
+
}).filter(item => item.score > 0)
|
|
312
|
+
} else {
|
|
313
|
+
results = results.map(item => ({ ...item, score: 1 }))
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// 标签筛选
|
|
317
|
+
if (selectedTag.value) {
|
|
318
|
+
results = results.filter(item => item.tags.includes(selectedTag.value))
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// 分类筛选
|
|
322
|
+
if (selectedCategory.value) {
|
|
323
|
+
results = results.filter(item => item.categories.includes(selectedCategory.value))
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// 排序
|
|
327
|
+
switch (sortBy.value) {
|
|
328
|
+
case 'date':
|
|
329
|
+
results.sort((a, b) => new Date(b.date).getTime() - new Date(a.date).getTime())
|
|
330
|
+
break
|
|
331
|
+
case 'title':
|
|
332
|
+
results.sort((a, b) => a.title.localeCompare(b.title))
|
|
333
|
+
break
|
|
334
|
+
case 'relevance':
|
|
335
|
+
default:
|
|
336
|
+
results.sort((a, b) => b.score - a.score)
|
|
337
|
+
break
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
searchResults.value = results
|
|
341
|
+
isSearching.value = false
|
|
342
|
+
searchTime.value = Math.round(performance.now() - startTime)
|
|
343
|
+
currentPage.value = 1
|
|
344
|
+
}, 200)
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
const handleSearch = () => {
|
|
348
|
+
performSearch()
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
const clearSearch = () => {
|
|
352
|
+
searchQuery.value = ''
|
|
353
|
+
selectedTag.value = ''
|
|
354
|
+
selectedCategory.value = ''
|
|
355
|
+
searchResults.value = []
|
|
356
|
+
currentPage.value = 1
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
const highlightText = (text: string) => {
|
|
360
|
+
if (!searchQuery.value) return text
|
|
361
|
+
|
|
362
|
+
const regex = new RegExp(`(${searchQuery.value})`, 'gi')
|
|
363
|
+
return text.replace(regex, '<mark class="bg-yellow-200 dark:bg-yellow-800 px-1 rounded">$1</mark>')
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
const formatDate = (date: string) => {
|
|
367
|
+
return new Intl.DateTimeFormat('zh-CN', {
|
|
368
|
+
year: 'numeric',
|
|
369
|
+
month: 'long',
|
|
370
|
+
day: 'numeric'
|
|
371
|
+
}).format(new Date(date))
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
const getPageNumbers = () => {
|
|
375
|
+
const maxPages = 5
|
|
376
|
+
const half = Math.floor(maxPages / 2)
|
|
377
|
+
let start = Math.max(1, currentPage.value - half)
|
|
378
|
+
let end = Math.min(totalPages.value, start + maxPages - 1)
|
|
379
|
+
|
|
380
|
+
if (end - start < maxPages - 1) {
|
|
381
|
+
start = Math.max(1, end - maxPages + 1)
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
const pages = []
|
|
385
|
+
for (let i = start; i <= end; i++) {
|
|
386
|
+
pages.push(i)
|
|
387
|
+
}
|
|
388
|
+
return pages
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
onMounted(() => {
|
|
392
|
+
// 如果URL中有搜索参数,自动执行搜索
|
|
393
|
+
const urlParams = new URLSearchParams(window.location.search)
|
|
394
|
+
const query = urlParams.get('q')
|
|
395
|
+
if (query) {
|
|
396
|
+
searchQuery.value = query
|
|
397
|
+
performSearch()
|
|
398
|
+
}
|
|
399
|
+
})
|
|
400
|
+
</script>
|
|
401
|
+
|
|
402
|
+
<style scoped>
|
|
403
|
+
.line-clamp-2 {
|
|
404
|
+
display: -webkit-box;
|
|
405
|
+
-webkit-line-clamp: 2;
|
|
406
|
+
-webkit-box-orient: vertical;
|
|
407
|
+
overflow: hidden;
|
|
408
|
+
}
|
|
409
|
+
</style>
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
<template>
|
|
2
|
+
<button
|
|
3
|
+
@click="toggleSidebar"
|
|
4
|
+
:title="isCollapsed ? '显示侧边栏' : '隐藏侧边栏'"
|
|
5
|
+
class="p-2 rounded-lg hover:bg-slate-100 dark:hover:bg-slate-800 transition-colors text-slate-600 hover:text-primary-500 dark:text-slate-400 dark:hover:text-primary-400"
|
|
6
|
+
>
|
|
7
|
+
<svg
|
|
8
|
+
class="w-4 h-4 transition-transform duration-200"
|
|
9
|
+
:class="{ 'rotate-180': isCollapsed }"
|
|
10
|
+
fill="none"
|
|
11
|
+
stroke="currentColor"
|
|
12
|
+
viewBox="0 0 24 24"
|
|
13
|
+
>
|
|
14
|
+
<path
|
|
15
|
+
stroke-linecap="round"
|
|
16
|
+
stroke-linejoin="round"
|
|
17
|
+
stroke-width="2"
|
|
18
|
+
d="M11 19l-7-7 7-7m8 14l-7-7 7-7"
|
|
19
|
+
/>
|
|
20
|
+
</svg>
|
|
21
|
+
</button>
|
|
22
|
+
</template>
|
|
23
|
+
|
|
24
|
+
<script setup lang="ts">
|
|
25
|
+
import { ref, onMounted, watch } from 'vue';
|
|
26
|
+
|
|
27
|
+
const isCollapsed = ref(false);
|
|
28
|
+
|
|
29
|
+
// 从localStorage恢复状态
|
|
30
|
+
onMounted(() => {
|
|
31
|
+
setTimeout(() => {
|
|
32
|
+
const saved = localStorage.getItem('sidebar-collapsed');
|
|
33
|
+
if (saved) {
|
|
34
|
+
isCollapsed.value = JSON.parse(saved);
|
|
35
|
+
}
|
|
36
|
+
applySidebarState();
|
|
37
|
+
}, 100);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
// 监听状态变化
|
|
41
|
+
watch(isCollapsed, (newValue) => {
|
|
42
|
+
localStorage.setItem('sidebar-collapsed', JSON.stringify(newValue));
|
|
43
|
+
applySidebarState();
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
function toggleSidebar() {
|
|
47
|
+
isCollapsed.value = !isCollapsed.value;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function applySidebarState() {
|
|
51
|
+
if (isCollapsed.value) {
|
|
52
|
+
document.body.classList.add('sidebar-collapsed');
|
|
53
|
+
} else {
|
|
54
|
+
document.body.classList.remove('sidebar-collapsed');
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
</script>
|