3636 </template >
3737 </div >
3838
39+ <!-- 外挂式文章列表:左侧抽屉 -->
40+ <button
41+ class =" articles-fab"
42+ type =" button"
43+ :aria-expanded =" listOpen ? 'true' : 'false'"
44+ aria-controls =" articles-drawer"
45+ @click =" openList"
46+ >
47+ 博客
48+ </button >
49+
50+ <div v-if =" listOpen" class =" articles-overlay" @click =" closeList" />
51+ <aside
52+ id =" articles-drawer"
53+ class =" articles-drawer"
54+ :class =" { open: listOpen }"
55+ role =" dialog"
56+ aria-label =" 博客文章列表"
57+ >
58+ <div class =" articles-drawer-head" >
59+ <div class =" articles-drawer-title" >博客</div >
60+ <button class =" articles-drawer-close" type =" button" aria-label =" 关闭文章列表" @click =" closeList" >
61+ ×
62+ </button >
63+ </div >
64+
65+ <div class =" articles-drawer-search" >
66+ <input v-model =" keyword" type =" text" class =" search-input" placeholder =" find something..." />
67+ </div >
68+
69+ <div class =" articles-drawer-body" >
70+ <div v-if =" loadingList" class =" sidebar-hint" style =" color : var (--muted )" >加载中...</div >
71+ <div v-else-if =" listItems.length === 0" class =" sidebar-hint" style =" color : var (--muted )" >暂无文章</div >
72+
73+ <button
74+ v-for =" a in listItems"
75+ :key =" a.id"
76+ class =" sidebar-item articles-drawer-item"
77+ :class =" { active: String(a.id) === String(route.params.id) }"
78+ type =" button"
79+ @click =" goArticle(a.id)"
80+ >
81+ <div class =" sidebar-item-title" >{{ a.title }}</div >
82+ <div class =" sidebar-item-date" >{{ formatDate(a.publishedAt) }}</div >
83+ </button >
84+ </div >
85+ </aside >
86+
3987 <!-- 外挂式目录:左侧抽屉 -->
4088 <button
4189 v-if =" tocItems.length"
79127</template >
80128
81129<script setup>
82- import { onBeforeUnmount , onMounted , ref , watchEffect } from ' vue'
83- import { useRoute } from ' vue-router'
130+ import { computed , onBeforeUnmount , onMounted , ref , watchEffect } from ' vue'
131+ import { useRoute , useRouter } from ' vue-router'
84132import DOMPurify from ' dompurify'
85133import { marked } from ' marked'
86134
87- import { fetchArticleDetail , coverUrl } from ' ../api/article'
135+ import { fetchArticleDetail , fetchArticles , coverUrl } from ' ../api/article'
88136import CommentSection from ' ../components/CommentSection.vue'
89137
90138const route = useRoute ()
139+ const router = useRouter ()
91140const loading = ref (true )
92141const article = ref ({})
93142const html = ref (' ' )
@@ -104,6 +153,43 @@ onMounted(async () => {
104153 }
105154})
106155
156+ const listOpen = ref (false )
157+ const loadingList = ref (true )
158+ const keyword = ref (' ' )
159+ const allArticles = ref ([])
160+
161+ onMounted (async () => {
162+ try {
163+ loadingList .value = true
164+ const resp = await fetchArticles ({ page: 0 , size: 200 })
165+ allArticles .value = resp? .items || []
166+ } finally {
167+ loadingList .value = false
168+ }
169+ })
170+
171+ const listItems = computed (() => {
172+ const k = (keyword .value || ' ' ).trim ().toLowerCase ()
173+ if (! k) return allArticles .value
174+ return allArticles .value .filter (a => (a .title || ' ' ).toLowerCase ().includes (k))
175+ })
176+
177+ function openList () {
178+ listOpen .value = true
179+ }
180+
181+ function closeList () {
182+ listOpen .value = false
183+ }
184+
185+ function goArticle (id ) {
186+ closeList ()
187+ tocOpen .value = false
188+ router .push (` /article/${ id} ` ).then (() => {
189+ window .scrollTo ({ top: 0 , behavior: ' auto' })
190+ })
191+ }
192+
107193watchEffect (() => {
108194 const md = article .value ? .contentMarkdown || ' '
109195 const rawHtml = marked .parse (md)
@@ -149,7 +235,10 @@ function jumpToHeading(id) {
149235}
150236
151237function onKeyDown (e ) {
152- if (e .key === ' Escape' && tocOpen .value ) tocOpen .value = false
238+ if (e .key === ' Escape' ) {
239+ if (listOpen .value ) listOpen .value = false
240+ if (tocOpen .value ) tocOpen .value = false
241+ }
153242}
154243
155244onMounted (() => window .addEventListener (' keydown' , onKeyDown))
0 commit comments