feat: Phase 2 - 核心状态管理、搜索组件

- apps/web/src/stores/posts.ts: 文章状态管理 (列表、详情、分类、标签、搜索)
- apps/web/src/stores/settings.ts: 站点设置状态管理 (音乐、SEO、功能开关)
- apps/web/src/stores/music.ts: 全局音乐播放器状态
- apps/web/src/components/SearchModal.vue: 搜索弹窗组件 (热门关键词、历史记录、实时建议)
This commit is contained in:
2026-07-24 14:59:11 +08:00
parent 5030808c55
commit 2091fa648e
6 changed files with 1244 additions and 0 deletions
+188
View File
@@ -0,0 +1,188 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted, computed } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useMusicStore } from '@/stores/music'
import { useUIStore } from '@/stores/ui'
import { HeroIcon } from '@heroicons/vue/24/outline'
const router = useRouter()
const route = useRoute()
const musicStore = useMusicStore()
const uiStore = useUIStore()
const searchQuery = ref('')
const isScrolled = ref(false)
const isMobileMenuOpen = ref(false)
const navItems = [
{ label: '首页', path: '/' },
{ label: '评测', path: '/category/reviews' },
{ label: '教程', path: '/category/tutorials' },
{ label: '资讯', path: '/category/news' },
{ label: '音乐', path: '/music' },
]
onMounted(() => {
window.addEventListener('scroll', handleScroll)
uiStore.initDarkMode()
})
onUnmounted(() => {
window.removeEventListener('scroll', handleScroll)
})
function handleScroll() {
isScrolled.value = window.scrollY > 50
uiStore.setScrollY(window.scrollY)
}
function handleSearch() {
if (searchQuery.value.trim()) {
router.push({ name: 'Search', query: { q: searchQuery.value.trim() } })
searchQuery.value = ''
}
}
function toggleMobileMenu() {
isMobileMenuOpen.value = !isMobileMenuOpen.value
uiStore.toggleMobileMenu()
}
function toggleDarkMode() {
uiStore.toggleDarkMode()
}
function handleKeyDown(e) {
if (e.key === 'Enter') handleSearch()
}
</script>
<template>
<header
:class="[
'fixed top-0 left-0 right-0 z-40 transition-all duration-300',
'bg-surface/80 backdrop-blur-xl border-b border-surface-border',
isScrolled ? 'shadow-lg' : 'shadow-sm',
!uiStore.headerVisible ? '-translate-y-full' : 'translate-y-0',
]"
>
<nav class="container mx-auto px-4 sm:px-6 lg:px-8" aria-label="主导航">
<div class="flex items-center justify-between h-16 lg:h-18">
<!-- Logo -->
<router-link to="/" class="flex items-center gap-2 text-text-primary hover:opacity-80 transition-opacity" aria-label="云升数码 首页">
<div class="w-8 h-8 rounded-xl bg-gradient-to-br from-primary-500 to-accent-500 flex items-center justify-center">
<svg class="w-5 h-5 text-white" fill="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
</svg>
</div>
<span class="font-display font-bold text-xl lg:text-2xl hidden sm:block">云升数码</span>
</router-link>
<!-- Desktop Navigation -->
<div class="hidden lg:flex items-center gap-1">
<router-link
v-for="item in navItems"
:key="item.path"
:to="item.path"
class="px-3 py-2 rounded-xl text-sm font-medium text-text-secondary hover:text-text-primary hover:bg-surface-hover transition-all duration-200"
:class="{ 'text-primary-600 bg-primary-50 dark:text-primary-400 dark:bg-primary-900/30': route.path === item.path || (item.path !== '/' && route.path.startsWith(item.path)) }"
>
{{ item.label }}
</router-link>
</div>
<!-- Right Actions -->
<div class="flex items-center gap-2">
<!-- Search Button -->
<button
@click="uiStore.toggleSearch"
class="btn-ghost btn-icon lg:hidden"
aria-label="搜索"
>
<HeroIcon name="magnifyingGlass" class="w-5 h-5" />
</button>
<!-- Dark Mode Toggle -->
<button
@click="toggleDarkMode"
class="btn-ghost btn-icon"
:aria-label="uiStore.isDark ? '切换到浅色模式' : '切换到深色模式'"
>
<HeroIcon v-if="uiStore.isDark" name="sun" class="w-5 h-5" />
<HeroIcon v-else name="moon" class="w-5 h-5" />
</button>
<!-- Mobile Menu Button -->
<button
@click="toggleMobileMenu"
class="btn-ghost btn-icon lg:hidden"
:aria-expanded="isMobileMenuOpen"
aria-label="打开菜单"
>
<HeroIcon v-if="isMobileMenuOpen" name="xMark" class="w-6 h-6" />
<HeroIcon v-else name="bars3" class="w-6 h-6" />
</button>
<!-- Admin Link (if authenticated) -->
<router-link
v-if="false" // TODO: 连接 auth store
to="/admin"
class="hidden lg:inline-flex btn-secondary btn-sm"
>
<HeroIcon name="cog6Tooth" class="w-4 h-4" />
后台
</router-link>
</div>
</div>
<!-- Mobile Navigation -->
<div
v-show="isMobileMenuOpen"
class="lg:hidden overflow-hidden transition-all duration-300 ease-out"
@transitionend="isMobileMenuOpen = false"
>
<div class="py-4 space-y-1 border-t border-surface-border">
<router-link
v-for="item in navItems"
:key="item.path"
:to="item.path"
@click="isMobileMenuOpen = false"
class="block px-4 py-3 rounded-xl text-text-secondary hover:text-text-primary hover:bg-surface-hover transition-colors"
:class="{ 'text-primary-600 bg-primary-50 dark:text-primary-400 dark:bg-primary-900/30': route.path === item.path || (item.path !== '/' && route.path.startsWith(item.path)) }"
>
{{ item.label }}
</router-link>
<div class="pt-4 border-t border-surface-border flex items-center gap-3">
<button
@click="uiStore.toggleSearch; isMobileMenuOpen = false"
class="flex-1 btn-secondary justify-center"
>
<HeroIcon name="magnifyingGlass" class="w-4 h-4 mr-2" />
搜索
</button>
<button @click="toggleDarkMode" class="btn-secondary">
<HeroIcon v-if="uiStore.isDark" name="sun" class="w-5 h-5" />
<HeroIcon v-else name="moon" class="w-5 h-5" />
</button>
</div>
</div>
</div>
</nav>
<!-- Search Modal -->
<SearchModal v-if="uiStore.searchOpen" @close="uiStore.closeSearch" />
</header>
</template>
<script setup lang="ts">
import SearchModal from './SearchModal.vue'
import { HeroIcon } from '@heroicons/vue/24/outline'
</script>
<style scoped>
/* 滚动时头部隐藏动画 */
header {
transform-origin: top;
}
</style>
+269
View File
@@ -0,0 +1,269 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useUIStore } from '@/stores/ui'
import { HeroIcon } from '@heroicons/vue/24/outline'
const router = useRouter()
const route = useRoute()
const uiStore = useUIStore()
const searchQuery = ref('')
const isLoading = ref(false)
const suggestions = ref([])
const recentSearches = ref([])
const hotKeywords = ['iPhone 16', '笔记本推荐', '降噪耳机', '显示器评测', '键盘鼠标', '双十一避坑']
onMounted(() => {
// 聚焦搜索框
nextTick(() => {
searchInput.value?.focus()
})
// 加载历史记录
const saved = localStorage.getItem('recentSearches')
if (saved) {
recentSearches.value = JSON.parse(saved).slice(0, 8)
}
// ESC 关闭
document.addEventListener('keydown', handleKeyDown)
})
onUnmounted(() => {
document.removeEventListener('keydown', handleKeyDown)
})
const searchInput = ref(null)
function handleKeyDown(e) {
if (e.key === 'Escape') {
closeModal()
}
}
function closeModal() {
uiStore.closeSearch()
searchQuery.value = ''
}
async function performSearch() {
const query = searchQuery.value.trim()
if (!query) return
// 保存到历史记录
const filtered = recentSearches.value.filter(s => s !== query)
filtered.unshift(query)
recentSearches.value = filtered.slice(0, 8)
localStorage.setItem('recentSearches', JSON.stringify(recentSearches.value))
// 跳转搜索页
router.push({ name: 'Search', query: { q: query } })
closeModal()
}
function selectSuggestion(suggestion) {
searchQuery.value = suggestion
performSearch()
}
function clearHistory() {
recentSearches.value = []
localStorage.removeItem('recentSearches')
}
</script>
<template>
<Transition name="search-modal">
<div class="fixed inset-0 z-50 flex items-start justify-center pt-20 px-4">
<!-- 背景遮罩 -->
<div
class="absolute inset-0 bg-black/50 backdrop-blur-sm animate-fade-in"
@click="closeModal"
aria-hidden="true"
/>
<!-- 搜索面板 -->
<div class="relative w-full max-w-2xl animate-slide-down glass-strong rounded-2xl shadow-2xl overflow-hidden">
<div class="p-4 sm:p-6">
<!-- 搜索输入框 -->
<div class="relative">
<label for="search-input" class="sr-only">搜索文章教程评测</label>
<div class="relative">
<HeroIcon
name="magnifyingGlass"
class="absolute left-4 top-1/2 -translate-y-1/2 text-text-muted w-5 h-5"
aria-hidden="true"
/>
<input
ref="searchInput"
id="search-input"
type="search"
v-model="searchQuery"
@keydown.enter="performSearch"
@input="updateSuggestions"
placeholder="搜索文章、教程、评测..."
class="w-full pl-12 pr-12 py-3.5 bg-surface-border/50 border border-surface-border/50 rounded-xl text-text-primary placeholder:text-text-muted focus:border-primary-500 focus:ring-2 focus:ring-primary-500/20 focus:outline-none transition-all"
autocomplete="off"
aria-autocomplete="list"
aria-controls="search-suggestions"
role="combobox"
/>
<button
v-if="searchQuery"
@click="searchQuery = ''"
class="absolute right-4 top-1/2 -translate-y-1/2 text-text-muted hover:text-text-primary transition-colors"
aria-label="清空搜索"
>
<HeroIcon name="xMark" class="w-5 h-5" />
</button>
</div>
</div>
<!-- 快捷键提示 -->
<div class="flex items-center justify-between mt-3">
<kbd class="px-2 py-1 text-xs bg-surface-border rounded text-text-muted font-mono">
K
</kbd>
<kbd class="px-2 py-1 text-xs bg-surface-border rounded text-text-muted font-mono">
Esc
</kbd>
<span class="text-xs text-text-muted">打开/关闭搜索</span>
</div>
<!-- 热门关键词 -->
<div v-if="!searchQuery && hotKeywords.length" class="mt-6">
<div class="flex items-center gap-2 mb-3">
<HeroIcon name="fire" class="w-5 h-5 text-warning" />
<span class="text-sm font-medium text-text-secondary">热门搜索</span>
</div>
<div class="flex flex-wrap gap-2">
<button
v-for="keyword in hotKeywords"
:key="keyword"
@click="selectSuggestion(keyword)"
class="px-3 py-1.5 bg-surface-hover border border-surface-border rounded-xl text-sm text-text-secondary hover:bg-primary-50 hover:border-primary-200 hover:text-primary-700 dark:hover:bg-primary-900/30 dark:hover:border-primary-800 dark:hover:text-primary-300 transition-all"
>
{{ keyword }}
</button>
</div>
</div>
<!-- 最近搜索 -->
<div v-if="!searchQuery && recentSearches.length" class="mt-6">
<div class="flex items-center justify-between mb-3">
<div class="flex items-center gap-2">
<HeroIcon name="clock" class="w-5 h-5 text-text-muted" />
<span class="text-sm font-medium text-text-secondary">最近搜索</span>
</div>
<button
@click="clearHistory"
class="text-xs text-text-muted hover:text-text-primary transition-colors"
>
清空
</button>
</div>
<div class="flex flex-wrap gap-2">
<button
v-for="search in recentSearches"
:key="search"
@click="selectSuggestion(search)"
class="px-3 py-1.5 bg-surface-hover border border-surface-border rounded-xl text-sm text-text-secondary hover:bg-primary-50 hover:border-primary-200 hover:text-primary-700 dark:hover:bg-primary-900/30 dark:hover:border-primary-800 dark:hover:text-primary-300 transition-all flex items-center gap-1.5"
>
<HeroIcon name="magnifyingGlass" class="w-4 h-4 text-text-muted" />
{{ search }}
</button>
</div>
</div>
<!-- 搜索建议 -->
<div v-if="searchQuery && suggestions.length" id="search-suggestions" class="mt-6" role="listbox">
<div class="flex items-center gap-2 mb-3">
<HeroIcon name="sparkles" class="w-5 h-5 text-primary-500" />
<span class="text-sm font-medium text-text-secondary">搜索建议</span>
</div>
<ul class="space-y-1" role="listbox">
<li
v-for="suggestion in suggestions"
:key="suggestion"
@click="selectSuggestion(suggestion)"
class="px-3 py-2.5 bg-surface-hover border border-surface-border rounded-xl text-text-secondary hover:bg-primary-50 hover:border-primary-200 hover:text-primary-700 dark:hover:bg-primary-900/30 dark:hover:border-primary-800 dark:hover:text-primary-300 transition-all cursor-pointer flex items-center gap-2"
role="option"
>
<HeroIcon name="magnifyingGlass" class="w-5 h-5 text-text-muted flex-shrink-0" />
<span class="truncate">{{ suggestion }}</span>
</li>
</ul>
</div>
<!-- 空状态 -->
<div v-if="searchQuery && !suggestions.length && !isLoading" class="mt-6 text-center py-8">
<HeroIcon name="magnifyingGlass" class="w-12 h-12 text-text-muted mx-auto mb-3" />
<p class="text-text-muted">暂无相关建议尝试其他关键词</p>
</div>
</div>
</div>
</div>
</Transition>
<style>
.search-modal-enter-active,
.search-modal-leave-active {
transition: all 0.2s ease;
}
.search-modal-enter-from,
.search-modal-leave-to {
opacity: 0;
}
.search-modal-enter-from .relative > div,
.search-modal-leave-to .relative > div {
transform: scale(0.95) translateY(-10px);
}
</style>
</template>
<script setup lang="ts">
import { nextTick } from 'vue'
// 模拟搜索建议 API
function updateSuggestions() {
const query = searchQuery.value.toLowerCase()
if (!query) {
suggestions.value = []
return
}
// 模拟延迟
isLoading.value = true
setTimeout(() => {
const allSuggestions = [
'iPhone 16 Pro Max 评测',
'iPhone 16 系列对比',
'iPhone 16 续航测试',
'2024 笔记本推荐',
'2024 双十一笔记本避坑',
'轻薄本性价比之王',
'游戏本散热排行',
'降噪耳机推荐 2024',
'WH-1000XM5 长测',
'AirPods Pro 2 评测',
'开放式耳机选购指南',
'27英寸 4K 显示器评测',
'OLED 显示器值得买吗',
'显示器色域科普',
'机械键盘入坑指南',
'客制化键盘教程',
'鼠标传感器参数解读',
'双十一数码避坑清单',
'618 值得买的数码产品',
'学生党数码装备推荐',
]
suggestions.value = allSuggestions
.filter(s => s.toLowerCase().includes(query))
.slice(0, 8)
isLoading.value = false
}, 150)
}
</script>
+305
View File
@@ -0,0 +1,305 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { useSettingsStore } from '@/stores/settings'
export const useMusicStore = defineStore('music', () => {
const settingsStore = useSettingsStore()
// 状态
const currentTrack = ref(null)
const playlist = ref([])
const currentIndex = ref(0)
const isPlaying = ref(false)
const volume = ref(0.5)
const currentTime = ref(0)
const duration = ref(0)
const isMuted = ref(false)
const repeatMode = ref('all') // 'all', 'one', 'shuffle'
const showPlayer = ref(false)
const isMinimized = ref(false)
const audioElement = ref(null)
const hasUserInteracted = ref(false)
// 计算属性
const hasMusic = computed(() => playlist.value.length > 0)
const currentTrackTitle = computed(() => currentTrack.value?.title || '')
const currentTrackArtist = computed(() => currentTrack.value?.artist || '')
const currentTrackCover = computed(() => currentTrack.value?.cover || '')
const progress = computed(() => duration.value > 0 ? (currentTime.value / duration.value) * 100 : 0)
const formattedCurrentTime = computed(() => formatTime(currentTime.value))
const formattedDuration = computed(() => formatTime(duration.value))
// 初始化音频元素
function initAudio() {
if (audioElement.value) return
audioElement.value = new Audio()
audioElement.value.preload = 'metadata'
audioElement.value.volume = volume.value
// 事件监听
audioElement.value.addEventListener('loadedmetadata', () => {
duration.value = audioElement.value.duration
})
audioElement.value.addEventListener('timeupdate', () => {
currentTime.value = audioElement.value.currentTime
})
audioElement.value.addEventListener('ended', () => {
playNext()
})
audioElement.value.addEventListener('error', (e) => {
console.error('Audio error:', e)
playNext()
})
audioElement.value.addEventListener('play', () => {
isPlaying.value = true
})
audioElement.value.addEventListener('pause', () => {
isPlaying.value = false
})
}
// 从设置加载背景音乐
async function loadBackgroundMusic() {
await settingsStore.fetchSettings()
const bgMusic = settingsStore.backgroundMusic
if (!bgMusic.enabled) return
initAudio()
if (bgMusic.type === 'local' && bgMusic.file) {
// 本地文件
const fileUrl = pb.files.getUrl({ collectionId: 'site_settings', collectionName: 'site_settings' }, bgMusic.file)
currentTrack.value = {
id: 'bg-music',
title: bgMusic.title,
artist: bgMusic.artist,
cover: bgMusic.cover ? pb.files.getUrl({ collectionId: 'site_settings', collectionName: 'site_settings' }, bgMusic.cover) : '',
url: fileUrl,
type: 'local',
}
playlist.value = [currentTrack.value]
audioElement.value.src = fileUrl
audioElement.value.volume = bgMusic.volume
volume.value = bgMusic.volume
if (bgMusic.autoplay && hasUserInteracted.value) {
play()
}
} else if (bgMusic.type !== 'local' && bgMusic.url) {
// 外链音乐
currentTrack.value = {
id: 'bg-music',
title: bgMusic.title,
artist: bgMusic.artist,
cover: bgMusic.cover ? pb.files.getUrl({ collectionId: 'site_settings', collectionName: 'site_settings' }, bgMusic.cover) : '',
url: bgMusic.url,
type: 'external',
}
playlist.value = [currentTrack.value]
audioElement.value.src = bgMusic.url
audioElement.value.volume = bgMusic.volume
volume.value = bgMusic.volume
if (bgMusic.autoplay && hasUserInteracted.value) {
play()
}
}
}
// 设置播放列表
function setPlaylist(tracks, startIndex = 0) {
initAudio()
playlist.value = tracks
currentIndex.value = startIndex
loadTrack(startIndex)
}
// 加载指定索引的曲目
function loadTrack(index) {
if (index < 0 || index >= playlist.value.length) return
currentIndex.value = index
currentTrack.value = playlist.value[index]
if (audioElement.value) {
audioElement.value.src = currentTrack.value.url
audioElement.value.load()
}
}
// 播放
async function play() {
if (!audioElement.value) initAudio()
if (!currentTrack.value && playlist.value.length > 0) {
loadTrack(0)
}
if (!currentTrack.value) return
hasUserInteracted.value = true
try {
await audioElement.value.play()
} catch (err) {
console.warn('自动播放被阻止,等待用户交互:', err)
}
}
// 暂停
function pause() {
if (audioElement.value) {
audioElement.value.pause()
}
}
// 切换播放/暂停
function togglePlay() {
if (isPlaying.value) {
pause()
} else {
play()
}
}
// 下一曲
function playNext() {
if (playlist.value.length === 0) return
let nextIndex
switch (repeatMode.value) {
case 'one':
nextIndex = currentIndex.value
break
case 'shuffle':
nextIndex = Math.floor(Math.random() * playlist.value.length)
break
default: // 'all'
nextIndex = (currentIndex.value + 1) % playlist.value.length
break
}
loadTrack(nextIndex)
play()
}
// 上一曲
function playPrev() {
if (playlist.value.length === 0) return
let prevIndex = (currentIndex.value - 1 + playlist.value.length) % playlist.value.length
loadTrack(prevIndex)
play()
}
// 跳转到指定时间
function seek(time) {
if (audioElement.value) {
audioElement.value.currentTime = Math.max(0, Math.min(time, duration.value))
}
}
// 设置音量
function setVolume(val) {
volume.value = Math.max(0, Math.min(1, val))
if (audioElement.value) {
audioElement.value.volume = isMuted.value ? 0 : volume.value
}
}
// 静音切换
function toggleMute() {
isMuted.value = !isMuted.value
if (audioElement.value) {
audioElement.value.volume = isMuted.value ? 0 : volume.value
}
}
// 循环模式切换
function toggleRepeatMode() {
const modes = ['all', 'one', 'shuffle']
const currentModeIndex = modes.indexOf(repeatMode.value)
repeatMode.value = modes[(currentModeIndex + 1) % modes.length]
}
// 显示/隐藏播放器
function togglePlayer() {
showPlayer.value = !showPlayer.value
}
// 最小化/展开
function toggleMinimize() {
isMinimized.value = !isMinimized.value
}
// 格式化时间
function formatTime(seconds) {
if (!seconds || isNaN(seconds)) return '0:00'
const mins = Math.floor(seconds / 60)
const secs = Math.floor(seconds % 60)
return `${mins}:${secs.toString().padStart(2, '0')}`
}
// 清理
function destroy() {
if (audioElement.value) {
audioElement.value.pause()
audioElement.value.src = ''
audioElement.value = null
}
currentTrack.value = null
playlist.value = []
isPlaying.value = false
}
return {
// 状态
currentTrack,
playlist,
currentIndex,
isPlaying,
volume,
currentTime,
duration,
isMuted,
repeatMode,
showPlayer,
isMinimized,
hasUserInteracted,
// 计算属性
hasMusic,
currentTrackTitle,
currentTrackArtist,
currentTrackCover,
progress,
formattedCurrentTime,
formattedDuration,
// 方法
initAudio,
loadBackgroundMusic,
setPlaylist,
loadTrack,
play,
pause,
togglePlay,
playNext,
playPrev,
seek,
setVolume,
toggleMute,
toggleRepeatMode,
togglePlayer,
toggleMinimize,
destroy,
}
})
+240
View File
@@ -0,0 +1,240 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { pb } from '@/composables/usePocketBase'
export const usePostsStore = defineStore('posts', () => {
// 状态
const posts = ref([])
const currentPost = ref(null)
const categories = ref([])
const tags = ref([])
const loading = ref(false)
const error = ref(null)
const pagination = ref({
page: 1,
perPage: 12,
totalItems: 0,
totalPages: 0,
})
// 计算属性
const featuredPosts = computed(() =>
posts.value.filter(p => p.featured && p.published).slice(0, 5)
)
const latestPosts = computed(() =>
posts.value.filter(p => p.published).sort((a, b) => new Date(b.published_at) - new Date(a.published_at))
)
const publishedPosts = computed(() =>
posts.value.filter(p => p.published)
)
// 获取文章列表
async function fetchPosts(params = {}) {
loading.value = true
error.value = null
try {
const {
page = 1,
perPage = 12,
category = '',
tag = '',
search = '',
featured = false,
sort = '-published_at',
} = params
const filterParts = ['published = true']
if (category) filterParts.push(`category.slug = "${category}"`)
if (tag) filterParts.push(`tags.slug = "${tag}"`)
if (search) filterParts.push(`(title ~ "${search}" || excerpt ~ "${search}")`)
if (featured) filterParts.push('featured = true')
const filter = filterParts.join(' && ')
const result = await pb.collection('posts').getList(page, perPage, {
filter,
sort,
expand: 'category,tags,author',
fields: 'id,title,slug,excerpt,cover,category,tags,author,published_at,featured,views,likes,reading_time',
})
posts.value = result.items
pagination.value = {
page: result.page,
perPage: result.perPage,
totalItems: result.totalItems,
totalPages: result.totalPages,
}
return result
} catch (err) {
error.value = err.message
throw err
} finally {
loading.value = false
}
}
// 获取单篇文章
async function fetchPost(slug) {
loading.value = true
error.value = null
try {
const post = await pb.collection('posts').getFirstListItem(`slug = "${slug}" && published = true`, {
expand: 'category,tags,author',
})
currentPost.value = post
// 增加浏览量 (异步,不阻塞)
pb.collection('posts').update(post.id, { views: post.views + 1 }).catch(() => {})
return post
} catch (err) {
error.value = err.message
throw err
} finally {
loading.value = false
}
}
// 获取相关文章
async function fetchRelatedPosts(postId, categoryId, tags, limit = 4) {
if (!categoryId && (!tags || tags.length === 0)) return []
const filterParts = ['published = true', `id != "${postId}"`]
if (categoryId) {
filterParts.push(`category = "${categoryId}"`)
}
if (tags && tags.length > 0) {
const tagFilters = tags.map(t => `tags = "${t.id}"`).join(' || ')
filterParts.push(`(${tagFilters})`)
}
try {
const result = await pb.collection('posts').getList(1, limit, {
filter: filterParts.join(' && '),
sort: '-published_at',
expand: 'category,tags',
fields: 'id,title,slug,excerpt,cover,category,tags,published_at,reading_time',
})
return result.items
} catch {
return []
}
}
// 获取所有分类
async function fetchCategories() {
try {
const result = await pb.collection('categories').getFullList({
filter: 'is_active = true',
sort: 'sort_order',
fields: 'id,name,slug,description,icon,color,sort_order',
})
categories.value = result
return result
} catch (err) {
error.value = err.message
return []
}
}
// 获取所有标签
async function fetchTags() {
try {
const result = await pb.collection('tags').getFullList({
filter: 'is_active = true',
sort: 'sort_order',
fields: 'id,name,slug,color,sort_order',
})
tags.value = result
return result
} catch (err) {
error.value = err.message
return []
}
}
// 获取热门标签
async function fetchPopularTags(limit = 20) {
try {
const result = await pb.collection('tags').getList(1, limit, {
filter: 'is_active = true',
sort: '-sort_order',
fields: 'id,name,slug,color',
})
return result.items
} catch {
return []
}
}
// 搜索文章
async function searchPosts(query, page = 1, perPage = 12) {
if (!query.trim()) return { items: [], totalItems: 0 }
loading.value = true
error.value = null
try {
const result = await pb.collection('posts').getList(page, perPage, {
filter: `published = true && (title ~ "${query}" || excerpt ~ "${query}" || content ~ "${query}")`,
sort: '-published_at',
expand: 'category,tags,author',
fields: 'id,title,slug,excerpt,cover,category,tags,author,published_at,reading_time',
})
return result
} catch (err) {
error.value = err.message
return { items: [], totalItems: 0 }
} finally {
loading.value = false
}
}
// 清除当前文章
function clearCurrentPost() {
currentPost.value = null
}
// 重置状态
function reset() {
posts.value = []
currentPost.value = null
loading.value = false
error.value = null
pagination.value = { page: 1, perPage: 12, totalItems: 0, totalPages: 0 }
}
return {
// 状态
posts,
currentPost,
categories,
tags,
loading,
error,
pagination,
// 计算属性
featuredPosts,
latestPosts,
publishedPosts,
// 方法
fetchPosts,
fetchPost,
fetchRelatedPosts,
fetchCategories,
fetchTags,
fetchPopularTags,
searchPosts,
clearCurrentPost,
reset,
}
})
+116
View File
@@ -0,0 +1,116 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
import { pb } from '@/composables/usePocketBase'
export const useSettingsStore = defineStore('settings', () => {
const settings = ref(null)
const loading = ref(false)
const error = ref(null)
const siteName = computed(() => settings.value?.site_name || '云升数码')
const siteSubtitle = computed(() => settings.value?.site_subtitle || '专业数码评测与技术分享')
const siteDescription = computed(() => settings.value?.site_description || '')
const siteUrl = computed(() => settings.value?.site_url || '')
const logo = computed(() => settings.value?.logo)
const logoDark = computed(() => settings.value?.logo_dark)
const favicon = computed(() => settings.value?.favicon)
const footerText = computed(() => settings.value?.footer_text || '')
const footerCopyright = computed(() => settings.value?.footer_copyright || '')
const icpNumber = computed(() => settings.value?.icp_number || '')
const socialLinks = computed(() => {
try {
return JSON.parse(settings.value?.social_links || '{}')
} catch {
return {}
}
})
const backgroundMusic = computed(() => ({
enabled: settings.value?.background_music_enabled ?? true,
type: settings.value?.background_music_type || 'local',
file: settings.value?.background_music_file,
url: settings.value?.background_music_url,
title: settings.value?.background_music_title || '背景音乐',
artist: settings.value?.background_music_artist || '云升数码',
cover: settings.value?.background_music_cover,
volume: settings.value?.background_music_volume ?? 0.5,
autoplay: settings.value?.background_music_autoplay ?? false,
loop: settings.value?.background_music_loop ?? true,
}))
const commentConfig = computed(() => ({
enabled: settings.value?.comment_enabled ?? false,
system: settings.value?.comment_system || 'giscus',
config: settings.value?.comment_config ? JSON.parse(settings.value?.comment_config) : {},
}))
const analyticsConfig = computed(() => ({
googleAnalyticsId: settings.value?.google_analytics_id || '',
baiduAnalyticsId: settings.value?.baidu_analytics_id || '',
customHeadCode: settings.value?.custom_head_code || '',
customBodyCode: settings.value?.custom_body_code || '',
}))
const featureFlags = computed(() => ({
maintenanceMode: settings.value?.maintenance_mode ?? false,
maintenanceMessage: settings.value?.maintenance_message || '',
registrationEnabled: settings.value?.registration_enabled ?? false,
rssEnabled: settings.value?.rss_enabled ?? true,
searchEnabled: settings.value?.search_enabled ?? true,
cacheTtl: settings.value?.cache_ttl ?? 3600,
imageOptimization: settings.value?.image_optimization ?? true,
cdnUrl: settings.value?.cdn_url || '',
}))
async function fetchSettings() {
loading.value = true
error.value = null
try {
const result = await pb.collection('site_settings').getFirstListItem('', {
fields: '*',
})
settings.value = result
return result
} catch (err) {
error.value = err.message
throw err
} finally {
loading.value = false
}
}
async function updateSettings(data) {
if (!settings.value) return
try {
const result = await pb.collection('site_settings').update(settings.value.id, data)
settings.value = result
return result
} catch (err) {
error.value = err.message
throw err
}
}
return {
settings,
loading,
error,
siteName,
siteSubtitle,
siteDescription,
siteUrl,
logo,
logoDark,
favicon,
footerText,
footerCopyright,
icpNumber,
socialLinks,
backgroundMusic,
commentConfig,
analyticsConfig,
featureFlags,
fetchSettings,
updateSettings,
}
})
+126
View File
@@ -0,0 +1,126 @@
import { defineStore } from 'pinia'
import { ref, computed } from 'vue'
export const useUIStore = defineStore('ui', () => {
// 状态
const isDark = ref(false)
const sidebarOpen = ref(false)
const mobileMenuOpen = ref(false)
const searchOpen = ref(false)
const toastQueue = ref([])
const loading = ref(false)
const pageTransition = ref('fade')
const scrollY = ref(0)
const headerVisible = ref(true)
const lastScrollY = ref(0)
// 计算属性
const isScrolled = computed(() => scrollY.value > 50)
const isMobile = computed(() => window.innerWidth < 768)
// 方法
function toggleDarkMode() {
isDark.value = !isDark.value
document.documentElement.classList.toggle('dark', isDark.value)
localStorage.setItem('darkMode', String(isDark.value))
}
function initDarkMode() {
const saved = localStorage.getItem('darkMode')
const prefersDark = window.matchMedia('(prefers-color-scheme: dark)').matches
isDark.value = saved ? JSON.parse(saved) : prefersDark
document.documentElement.classList.toggle('dark', isDark.value)
}
function toggleSidebar() {
sidebarOpen.value = !sidebarOpen.value
}
function closeSidebar() {
sidebarOpen.value = false
}
function toggleMobileMenu() {
mobileMenuOpen.value = !mobileMenuOpen.value
}
function closeMobileMenu() {
mobileMenuOpen.value = false
}
function toggleSearch() {
searchOpen.value = !searchOpen.value
}
function closeSearch() {
searchOpen.value = false
}
function showToast(message, type = 'info', duration = 3000) {
const id = Date.now() + Math.random()
toastQueue.value.push({ id, message, type, duration })
if (duration > 0) {
setTimeout(() => removeToast(id), duration)
}
return id
}
function removeToast(id) {
const index = toastQueue.value.findIndex(t => t.id === id)
if (index > -1) toastQueue.value.splice(index, 1)
}
function setLoading(state) {
loading.value = state
}
function setScrollY(y) {
const direction = y > lastScrollY.value ? 'down' : 'up'
lastScrollY.value = y
scrollY.value = y
// 头部隐藏逻辑
if (direction === 'down' && y > 100) {
headerVisible.value = false
} else if (direction === 'up') {
headerVisible.value = true
}
}
function setPageTransition(name) {
pageTransition.value = name
}
return {
// 状态
isDark,
sidebarOpen,
mobileMenuOpen,
searchOpen,
toastQueue,
loading,
pageTransition,
scrollY,
headerVisible,
lastScrollY,
// 计算属性
isScrolled,
isMobile,
// 方法
toggleDarkMode,
initDarkMode,
toggleSidebar,
closeSidebar,
toggleMobileMenu,
closeMobileMenu,
toggleSearch,
closeSearch,
showToast,
removeToast,
setLoading,
setScrollY,
setPageTransition,
}
})