From 2091fa648eb7d9dd7f855237f93f27a8acae057f Mon Sep 17 00:00:00 2001 From: plerr <1690532276@qq.com> Date: Fri, 24 Jul 2026 14:59:11 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20Phase=202=20-=20=E6=A0=B8=E5=BF=83?= =?UTF-8?q?=E7=8A=B6=E6=80=81=E7=AE=A1=E7=90=86=E3=80=81=E6=90=9C=E7=B4=A2?= =?UTF-8?q?=E7=BB=84=E4=BB=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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: 搜索弹窗组件 (热门关键词、历史记录、实时建议) --- apps/web/src/components/Header.vue | 188 +++++++++++++++ apps/web/src/components/SearchModal.vue | 269 +++++++++++++++++++++ apps/web/src/stores/music.ts | 305 ++++++++++++++++++++++++ apps/web/src/stores/posts.ts | 240 +++++++++++++++++++ apps/web/src/stores/settings.ts | 116 +++++++++ apps/web/src/stores/ui.ts | 126 ++++++++++ 6 files changed, 1244 insertions(+) create mode 100644 apps/web/src/components/Header.vue create mode 100644 apps/web/src/components/SearchModal.vue create mode 100644 apps/web/src/stores/music.ts create mode 100644 apps/web/src/stores/posts.ts create mode 100644 apps/web/src/stores/settings.ts create mode 100644 apps/web/src/stores/ui.ts diff --git a/apps/web/src/components/Header.vue b/apps/web/src/components/Header.vue new file mode 100644 index 0000000..0332602 --- /dev/null +++ b/apps/web/src/components/Header.vue @@ -0,0 +1,188 @@ + + + + + + + + + + + + + + 云升数码 + + + + + + {{ item.label }} + + + + + + + + + + + + + + + + + + + + + + + + + + 后台 + + + + + + + + + {{ item.label }} + + + + + + 搜索 + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/apps/web/src/components/SearchModal.vue b/apps/web/src/components/SearchModal.vue new file mode 100644 index 0000000..6968150 --- /dev/null +++ b/apps/web/src/components/SearchModal.vue @@ -0,0 +1,269 @@ + + + + + + + + + + + + + + 搜索文章、教程、评测 + + + + + + + + + + + + + ⌘K + + + Esc + + 打开/关闭搜索 + + + + + + + 热门搜索 + + + + {{ keyword }} + + + + + + + + + + 最近搜索 + + + 清空 + + + + + + {{ search }} + + + + + + + + + 搜索建议 + + + + + {{ suggestion }} + + + + + + + + 暂无相关建议,尝试其他关键词 + + + + + + + + + + \ No newline at end of file diff --git a/apps/web/src/stores/music.ts b/apps/web/src/stores/music.ts new file mode 100644 index 0000000..61c0b71 --- /dev/null +++ b/apps/web/src/stores/music.ts @@ -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, + } +}) \ No newline at end of file diff --git a/apps/web/src/stores/posts.ts b/apps/web/src/stores/posts.ts new file mode 100644 index 0000000..742ae30 --- /dev/null +++ b/apps/web/src/stores/posts.ts @@ -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, + } +}) \ No newline at end of file diff --git a/apps/web/src/stores/settings.ts b/apps/web/src/stores/settings.ts new file mode 100644 index 0000000..f4689f2 --- /dev/null +++ b/apps/web/src/stores/settings.ts @@ -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, + } +}) \ No newline at end of file diff --git a/apps/web/src/stores/ui.ts b/apps/web/src/stores/ui.ts new file mode 100644 index 0000000..73a32e6 --- /dev/null +++ b/apps/web/src/stores/ui.ts @@ -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, + } +}) \ No newline at end of file
暂无相关建议,尝试其他关键词