432 lines
13 KiB
Vue
432 lines
13 KiB
Vue
<template>
|
||
<section id="blog" class="page-section block animate-slide-down">
|
||
<div class="max-w-4xl mx-auto pt-32 px-6 pb-20">
|
||
<div class="mb-16 text-center">
|
||
<h2 class="font-serif text-5xl italic text-art-text mb-6">深度思考</h2>
|
||
<p class="text-art-muted max-w-lg mx-auto mb-8">关于前端技术、交互设计以及数字艺术的深度思考。</p>
|
||
|
||
<!-- Search UI -->
|
||
<div class="max-w-md mx-auto relative mb-6">
|
||
<input
|
||
type="text"
|
||
v-model="searchQuery"
|
||
@focus="handleSearchFocus"
|
||
@blur="handleSearchBlur"
|
||
@keyup.enter="handleSearch"
|
||
placeholder="搜索文章标题或内容..."
|
||
class="w-full bg-art-text/5 border border-art-border rounded-full px-6 py-3 text-art-text placeholder-art-muted focus:outline-none focus:border-art-accent transition-colors"
|
||
/>
|
||
<SearchSuggestDropdown
|
||
:visible="showSearchSuggest"
|
||
:history="searchHistory"
|
||
:hot-keywords="hotKeywords"
|
||
:loading-hot="loadingHot"
|
||
@select="selectSuggestKeyword"
|
||
@remove-history="removeSearchHistoryItem"
|
||
@clear-history="clearSearchHistory"
|
||
/>
|
||
<button
|
||
v-if="searchQuery"
|
||
@click="clearSearch"
|
||
class="absolute right-12 top-1/2 -translate-y-1/2 text-art-muted hover:text-art-text transition-colors"
|
||
>
|
||
<i data-lucide="x" class="w-4 h-4"></i>
|
||
</button>
|
||
<button
|
||
@click="handleSearch"
|
||
class="absolute right-4 top-1/2 -translate-y-1/2 text-art-muted hover:text-art-accent transition-colors"
|
||
>
|
||
<i data-lucide="search" class="w-5 h-5"></i>
|
||
</button>
|
||
</div>
|
||
|
||
<!-- Filter UI -->
|
||
<div class="max-w-2xl mx-auto flex flex-wrap gap-4 justify-center items-center">
|
||
<div class="flex items-center gap-2">
|
||
<label class="text-sm text-art-muted">分类:</label>
|
||
<div class="w-40">
|
||
<CustomSelect
|
||
v-model="selectedCategoryId"
|
||
:options="categoryOptions"
|
||
placeholder="全部"
|
||
@update:modelValue="applyFilters"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="flex items-center gap-2">
|
||
<label class="text-sm text-art-muted">标签:</label>
|
||
<div class="w-40">
|
||
<CustomSelect
|
||
v-model="selectedTagId"
|
||
:options="tagOptions"
|
||
placeholder="全部"
|
||
@update:modelValue="applyFilters"
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
<button
|
||
v-if="hasActiveFilters"
|
||
@click="clearFilters"
|
||
class="text-xs text-art-accent hover:text-art-text transition-colors px-3 py-1 border border-art-accent/30 rounded hover:border-art-accent"
|
||
>
|
||
清除筛选
|
||
</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Loading state -->
|
||
<div v-if="loading" class="flex justify-center items-center h-64">
|
||
<div class="animate-spin rounded-full h-12 w-12 border-t-2 border-b-2 border-art-accent"></div>
|
||
</div>
|
||
|
||
<!-- Error state -->
|
||
<div v-else-if="error" class="text-center text-red-500 py-20">
|
||
<p class="mb-4">{{ error }}</p>
|
||
<button @click="() => fetchBlogPosts(true)" class="px-4 py-2 bg-art-accent text-art-text rounded hover:bg-opacity-80 transition-colors">
|
||
重试
|
||
</button>
|
||
</div>
|
||
|
||
<!-- Empty state -->
|
||
<div v-else-if="blogPosts.length === 0" class="text-center py-20 text-art-muted">
|
||
<p>没有找到相关文章</p>
|
||
<button v-if="isSearching" @click="clearSearch" class="mt-4 text-art-accent hover:underline">
|
||
清除搜索条件
|
||
</button>
|
||
</div>
|
||
|
||
<!-- Blog posts list -->
|
||
<div v-else class="space-y-12" id="blog-list-container">
|
||
<!-- Blog posts will be rendered here -->
|
||
<article
|
||
v-for="post in blogPosts"
|
||
:key="post.id"
|
||
class="group cursor-pointer border-b border-art-border pb-12"
|
||
@click="$router.push('/blog/' + post.id)"
|
||
>
|
||
<div class="flex flex-col md:flex-row gap-8 items-start">
|
||
<div class="md:w-1/4 pt-2">
|
||
<span class="font-mono text-xs text-art-accent block mb-1">{{ post.categoryName || post.category?.name || '未分类' }}</span>
|
||
<span class="text-sm text-art-muted">{{ post.date }}</span>
|
||
<AuthorInfo
|
||
class="mt-3"
|
||
:name="post.userName"
|
||
:email="post.userEmail"
|
||
:avatar="post.userAvatar"
|
||
:user-id="post.userId"
|
||
variant="compact"
|
||
size="sm"
|
||
/>
|
||
<div v-if="post.tags && post.tags.length > 0" class="flex flex-wrap gap-2 mt-2">
|
||
<span v-for="tag in post.tags" :key="tag.id" class="text-xs text-art-muted bg-art-text/5 px-1 rounded">#{{ tag.name }}</span>
|
||
</div>
|
||
</div>
|
||
<div class="md:w-3/4 space-y-4">
|
||
<h3 class="font-serif text-3xl text-art-text group-hover:text-art-accent transition-colors leading-tight" v-html="highlightText(post.title)">
|
||
</h3>
|
||
<p class="text-art-muted font-light leading-relaxed" v-html="highlightText(post.excerpt)">
|
||
</p>
|
||
<div class="text-xs text-art-accent font-medium mt-4 group-hover:text-art-text transition-colors">阅读全文 -></div>
|
||
</div>
|
||
</div>
|
||
</article>
|
||
|
||
<!-- Loading more indicator -->
|
||
<div v-if="isLoadingMore" class="flex justify-center items-center py-8">
|
||
<div class="animate-spin rounded-full h-8 w-8 border-t-2 border-b-2 border-art-accent"></div>
|
||
</div>
|
||
|
||
<!-- No more posts indicator -->
|
||
<div v-else-if="!hasMore && blogPosts.length > 0" class="text-center py-8 text-art-muted text-sm">
|
||
没有更多文章了
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { ref, onMounted, nextTick, computed, onBeforeUnmount } from 'vue'
|
||
import { fetchPosts, fetchCategories, fetchTags, getPublicSettings, getHotSearches, Post, Category, Tag, PaginationResponse, HotSearchKeyword } from '../services/api'
|
||
import { useScrollAnimation } from '../composables/useScrollAnimation'
|
||
import { useSearchHistory } from '../composables/useSearchHistory'
|
||
import CustomSelect from '../components/CustomSelect.vue'
|
||
import SearchSuggestDropdown from '../components/SearchSuggestDropdown.vue'
|
||
import AuthorInfo from '../components/AuthorInfo.vue'
|
||
|
||
const { getHistory, addHistory, removeHistory, clearHistory } = useSearchHistory()
|
||
|
||
const blogPosts = ref<Post[]>([])
|
||
const loading = ref(false)
|
||
const error = ref('')
|
||
const searchQuery = ref('')
|
||
const isSearching = ref(false)
|
||
const selectedCategoryId = ref(0)
|
||
const selectedTagId = ref(0)
|
||
const categories = ref<Category[]>([])
|
||
const tags = ref<Tag[]>([])
|
||
const showSearchSuggest = ref(false)
|
||
const searchHistory = ref<string[]>([])
|
||
const hotKeywords = ref<HotSearchKeyword[]>([])
|
||
const loadingHot = ref(false)
|
||
let blurTimer: ReturnType<typeof setTimeout> | null = null
|
||
|
||
// 分页相关状态
|
||
const currentPage = ref(1)
|
||
const pageSize = ref(10)
|
||
const total = ref(0)
|
||
const hasMore = ref(true)
|
||
const isLoadingMore = ref(false)
|
||
|
||
const { initObserver } = useScrollAnimation()
|
||
|
||
// 滚动监听器
|
||
let scrollHandler: (() => void) | null = null
|
||
|
||
const hasActiveFilters = computed(() => {
|
||
return selectedCategoryId.value > 0 || selectedTagId.value > 0
|
||
})
|
||
|
||
// 计算属性:分类选项
|
||
const categoryOptions = computed(() => {
|
||
return [
|
||
{ value: 0, label: '全部' },
|
||
...categories.value.map(cat => ({ value: cat.id, label: cat.name }))
|
||
]
|
||
})
|
||
|
||
// 计算属性:标签选项
|
||
const tagOptions = computed(() => {
|
||
return [
|
||
{ value: 0, label: '全部' },
|
||
...tags.value.map(tag => ({ value: tag.id, label: tag.name }))
|
||
]
|
||
})
|
||
|
||
const refreshIcons = () => {
|
||
if ((window as any).lucide) {
|
||
(window as any).lucide.createIcons()
|
||
}
|
||
}
|
||
|
||
const fetchBlogPosts = async (reset: boolean = true) => {
|
||
if (reset) {
|
||
loading.value = true
|
||
currentPage.value = 1
|
||
blogPosts.value = []
|
||
} else {
|
||
isLoadingMore.value = true
|
||
}
|
||
|
||
error.value = ''
|
||
try {
|
||
const query = searchQuery.value.trim() || undefined
|
||
const categoryId = selectedCategoryId.value > 0 ? selectedCategoryId.value : undefined
|
||
const tagId = selectedTagId.value > 0 ? selectedTagId.value : undefined
|
||
|
||
const response: PaginationResponse<Post> = await fetchPosts(
|
||
query,
|
||
categoryId,
|
||
tagId,
|
||
undefined,
|
||
currentPage.value,
|
||
pageSize.value
|
||
)
|
||
|
||
if (reset) {
|
||
blogPosts.value = response.list
|
||
} else {
|
||
blogPosts.value = [...blogPosts.value, ...response.list]
|
||
}
|
||
|
||
total.value = response.total
|
||
hasMore.value = blogPosts.value.length < total.value
|
||
|
||
nextTick(() => {
|
||
initObserver()
|
||
refreshIcons()
|
||
})
|
||
} catch (err) {
|
||
console.error('Error fetching blog posts:', err)
|
||
error.value = '获取博客文章失败,请稍后重试'
|
||
} finally {
|
||
loading.value = false
|
||
isLoadingMore.value = false
|
||
}
|
||
}
|
||
|
||
// 加载更多
|
||
const loadMore = async () => {
|
||
if (isLoadingMore.value || !hasMore.value) return
|
||
|
||
currentPage.value++
|
||
await fetchBlogPosts(false)
|
||
}
|
||
|
||
// 触底加载
|
||
const setupScrollListener = () => {
|
||
scrollHandler = () => {
|
||
const scrollTop = window.pageYOffset || document.documentElement.scrollTop
|
||
const windowHeight = window.innerHeight
|
||
const documentHeight = document.documentElement.scrollHeight
|
||
|
||
// 当距离底部 200px 时触发加载
|
||
if (scrollTop + windowHeight >= documentHeight - 200) {
|
||
loadMore()
|
||
}
|
||
}
|
||
|
||
window.addEventListener('scroll', scrollHandler, { passive: true })
|
||
}
|
||
|
||
// 移除滚动监听
|
||
const removeScrollListener = () => {
|
||
if (scrollHandler) {
|
||
window.removeEventListener('scroll', scrollHandler)
|
||
scrollHandler = null
|
||
}
|
||
}
|
||
|
||
const handleSearch = () => {
|
||
if (!searchQuery.value.trim() && !isSearching.value) return
|
||
isSearching.value = !!searchQuery.value.trim()
|
||
if (searchQuery.value.trim()) {
|
||
addHistory(searchQuery.value.trim())
|
||
searchHistory.value = getHistory()
|
||
}
|
||
showSearchSuggest.value = false
|
||
fetchBlogPosts(true)
|
||
}
|
||
|
||
const handleSearchFocus = async () => {
|
||
if (blurTimer) {
|
||
clearTimeout(blurTimer)
|
||
blurTimer = null
|
||
}
|
||
searchHistory.value = getHistory()
|
||
showSearchSuggest.value = true
|
||
if (hotKeywords.value.length === 0 && !loadingHot.value) {
|
||
loadingHot.value = true
|
||
try {
|
||
hotKeywords.value = await getHotSearches()
|
||
} catch (err) {
|
||
console.error('Failed to load hot searches:', err)
|
||
} finally {
|
||
loadingHot.value = false
|
||
}
|
||
}
|
||
}
|
||
|
||
const handleSearchBlur = () => {
|
||
blurTimer = setTimeout(() => {
|
||
showSearchSuggest.value = false
|
||
}, 150)
|
||
}
|
||
|
||
const selectSuggestKeyword = (keyword: string) => {
|
||
searchQuery.value = keyword
|
||
handleSearch()
|
||
}
|
||
|
||
const removeSearchHistoryItem = (keyword: string) => {
|
||
removeHistory(keyword)
|
||
searchHistory.value = getHistory()
|
||
}
|
||
|
||
const clearSearchHistory = () => {
|
||
clearHistory()
|
||
searchHistory.value = []
|
||
}
|
||
|
||
const clearSearch = () => {
|
||
searchQuery.value = ''
|
||
isSearching.value = false
|
||
fetchBlogPosts(true)
|
||
}
|
||
|
||
const applyFilters = () => {
|
||
fetchBlogPosts(true)
|
||
}
|
||
|
||
const clearFilters = () => {
|
||
selectedCategoryId.value = 0
|
||
selectedTagId.value = 0
|
||
fetchBlogPosts(true)
|
||
}
|
||
|
||
const loadFilters = async () => {
|
||
try {
|
||
const [cats, tagList] = await Promise.all([
|
||
fetchCategories(),
|
||
fetchTags()
|
||
])
|
||
categories.value = cats
|
||
tags.value = tagList
|
||
} catch (err) {
|
||
console.error('Error loading filters:', err)
|
||
}
|
||
}
|
||
|
||
const highlightText = (text: string | undefined) => {
|
||
if (!text) return ''
|
||
if (!isSearching.value || !searchQuery.value) return text
|
||
|
||
const regex = new RegExp(`(${searchQuery.value})`, 'gi')
|
||
return text.replace(regex, '<span class="text-art-accent bg-art-accent/10 font-bold">$1</span>')
|
||
}
|
||
|
||
onMounted(async () => {
|
||
// 从配置读取每页数量
|
||
try {
|
||
const settings = await getPublicSettings()
|
||
const postsPerPage = settings.posts_per_page
|
||
if (postsPerPage) {
|
||
const size = parseInt(postsPerPage, 10)
|
||
if (size > 0) {
|
||
pageSize.value = size
|
||
}
|
||
}
|
||
} catch (err) {
|
||
console.error('Failed to load posts_per_page setting:', err)
|
||
}
|
||
|
||
// 先加载筛选选项
|
||
await loadFilters()
|
||
|
||
// 从 sessionStorage 读取传递的参数(不在URL显示)
|
||
const categoryId = sessionStorage.getItem('blogFilterCategoryId')
|
||
const tagId = sessionStorage.getItem('blogFilterTagId')
|
||
|
||
// 如果存在参数,设置对应的选中值
|
||
if (categoryId) {
|
||
const id = Number(categoryId)
|
||
if (id > 0) {
|
||
selectedCategoryId.value = id
|
||
// 读取后清除,避免下次访问时仍然应用
|
||
sessionStorage.removeItem('blogFilterCategoryId')
|
||
}
|
||
}
|
||
|
||
if (tagId) {
|
||
const id = Number(tagId)
|
||
if (id > 0) {
|
||
selectedTagId.value = id
|
||
// 读取后清除,避免下次访问时仍然应用
|
||
sessionStorage.removeItem('blogFilterTagId')
|
||
}
|
||
}
|
||
|
||
// 应用筛选并获取文章列表
|
||
await fetchBlogPosts(true)
|
||
refreshIcons()
|
||
|
||
// 设置滚动监听
|
||
setupScrollListener()
|
||
})
|
||
|
||
onBeforeUnmount(() => {
|
||
removeScrollListener()
|
||
})
|
||
</script> |