Files
nl-blogs/client/src/pages/Blog.vue
2026-07-14 12:47:53 +08:00

432 lines
13 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>