Compare commits
5 Commits
5030808c55
..
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 086c4d37ab | |||
| c554085ed8 | |||
| 31ac000856 | |||
| a6e331d51c | |||
| 2091fa648e |
@@ -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>
|
||||||
@@ -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>
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
})
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
})
|
||||||
+605
@@ -0,0 +1,605 @@
|
|||||||
|
# 云升数码 - 部署教程
|
||||||
|
|
||||||
|
> 一文搞定从零到线上的完整部署流程,支持 Linux / Windows / Docker 三种方式
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 目录
|
||||||
|
|
||||||
|
- [准备工作](#准备工作)
|
||||||
|
- [方式一:Linux 一键部署(推荐)](#方式一linux-一键部署推荐)
|
||||||
|
- [方式二:Windows 部署](#方式二windows-部署)
|
||||||
|
- [方式三:Docker 容器化部署](#方式三docker-容器化部署)
|
||||||
|
- [域名与 SSL 配置](#域名与-ssl-配置)
|
||||||
|
- [后台初始化](#后台初始化)
|
||||||
|
- [常用维护命令](#常用维护命令)
|
||||||
|
- [故障排查](#故障排查)
|
||||||
|
- [进阶配置](#进阶配置)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 准备工作
|
||||||
|
|
||||||
|
### 服务器要求
|
||||||
|
|
||||||
|
| 组件 | 最低配置 | 推荐配置 |
|
||||||
|
|------|----------|----------|
|
||||||
|
| CPU | 1 核 | 2 核+ |
|
||||||
|
| 内存 | 1 GB | 2 GB+ |
|
||||||
|
| 硬盘 | 10 GB | 50 GB+ (SSD) |
|
||||||
|
| 系统 | Ubuntu 22.04+ / Debian 12+ / CentOS 9+ / Windows Server 2019+ | Ubuntu 24.04 LTS |
|
||||||
|
| 带宽 | 5 Mbps | 20 Mbps+ |
|
||||||
|
|
||||||
|
### 必备条件
|
||||||
|
|
||||||
|
- ✅ 一台云服务器(腾讯云/阿里云/华为云/轻量应用服务器均可)
|
||||||
|
- ✅ 一个已备案域名(中国大陆节点必须备案)
|
||||||
|
- ✅ 域名已解析到服务器公网 IP(A 记录)
|
||||||
|
- ✅ SSH 客户端(Windows 推荐 Terminal / Tabby,Mac/Linux 自带 Terminal)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 方式一:Linux 一键部署(推荐)
|
||||||
|
|
||||||
|
### 适用系统
|
||||||
|
|
||||||
|
- Ubuntu 22.04 / 24.04 LTS
|
||||||
|
- Debian 12 (Bookworm)
|
||||||
|
- CentOS / Rocky Linux / AlmaLinux 9+
|
||||||
|
|
||||||
|
### 步骤 1:连接服务器
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 使用 SSH 连接(替换为你的服务器 IP)
|
||||||
|
ssh root@your-server-ip
|
||||||
|
|
||||||
|
# 或使用密钥登录
|
||||||
|
ssh -i ~/.ssh/id_rsa root@your-server-ip
|
||||||
|
```
|
||||||
|
|
||||||
|
### 步骤 2:下载并运行安装脚本
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 克隆项目仓库
|
||||||
|
git clone https://git.grxiao.cn/yuns/plerr-open.git
|
||||||
|
cd plerr-open
|
||||||
|
|
||||||
|
# 2. 赋予脚本执行权限
|
||||||
|
chmod +x scripts/install.sh
|
||||||
|
|
||||||
|
# 3. 运行一键安装(需要 root 权限)
|
||||||
|
sudo ./scripts/install.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
### 步骤 3:按提示输入信息
|
||||||
|
|
||||||
|
脚本会交互式询问:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
请输入域名 (例: yunsheng.digital): your-domain.com
|
||||||
|
请输入邮箱 (用于 SSL 证书): admin@your-domain.com
|
||||||
|
```
|
||||||
|
|
||||||
|
> 💡 **提示**:如果不想交互,可直接传参:
|
||||||
|
> ```bash
|
||||||
|
> sudo ./scripts/install.sh your-domain.com admin@your-domain.com
|
||||||
|
> ```
|
||||||
|
|
||||||
|
### 步骤 4:等待部署完成
|
||||||
|
|
||||||
|
脚本会自动完成以下工作(约 3-5 分钟):
|
||||||
|
|
||||||
|
1. 📦 安装系统依赖
|
||||||
|
2. ⬇️ 下载 PocketBase v0.22.0
|
||||||
|
3. ⚙️ 配置 systemd 服务
|
||||||
|
4. 🌐 配置 Nginx 反向代理
|
||||||
|
5. 🔒 申请 Let's Encrypt SSL 证书
|
||||||
|
6. 🛡️ 配置防火墙 & Fail2Ban
|
||||||
|
7. 📝 创建维护脚本 & 定时任务
|
||||||
|
8. 🚀 启动所有服务
|
||||||
|
|
||||||
|
### 步骤 5:访问验证
|
||||||
|
|
||||||
|
部署成功后会显示:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
==========================================
|
||||||
|
部署完成!
|
||||||
|
==========================================
|
||||||
|
|
||||||
|
📋 重要信息:
|
||||||
|
- 网站地址: https://your-domain.com
|
||||||
|
- 后台管理: https://your-domain.com/_/
|
||||||
|
- 管理员账号: admin@yunsheng.digital
|
||||||
|
- 管理员密码: YunSheng@2024!Admin
|
||||||
|
- PocketBase 数据目录: /opt/pocketbase/pb_data
|
||||||
|
- 前端构建目录: /opt/pocketbase/pb_public
|
||||||
|
```
|
||||||
|
|
||||||
|
立即访问 `https://your-domain.com` 查看网站,访问 `https://your-domain.com/_/` 进入后台管理。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🪟 方式二:Windows 部署
|
||||||
|
|
||||||
|
### 适用系统
|
||||||
|
|
||||||
|
- Windows 10/11 (专业版/企业版)
|
||||||
|
- Windows Server 2019/2022
|
||||||
|
|
||||||
|
### 前置要求
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# 1. 以管理员身份打开 PowerShell
|
||||||
|
# 2. 启用脚本执行策略
|
||||||
|
Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser
|
||||||
|
|
||||||
|
# 3. 安装 Chocolatey (如果未安装)
|
||||||
|
Set-ExecutionPolicy Bypass -Scope Process -Force
|
||||||
|
[System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor 3072
|
||||||
|
iex ((New-Object System.Net.WebClient).DownloadString('https://community.chocolatey.org/install.ps1'))
|
||||||
|
refreshenv
|
||||||
|
```
|
||||||
|
|
||||||
|
### 运行安装脚本
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# 1. 克隆仓库
|
||||||
|
git clone https://git.grxiao.cn/yuns/plerr-open.git
|
||||||
|
cd plerr-open
|
||||||
|
|
||||||
|
# 2. 运行安装脚本 (管理员 PowerShell)
|
||||||
|
.\scripts\install.ps1
|
||||||
|
|
||||||
|
# 或传参
|
||||||
|
.\scripts\install.ps1 -Domain "your-domain.com" -Email "admin@your-domain.com"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Windows 特有说明
|
||||||
|
|
||||||
|
| 组件 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| PocketBase | 通过 NSSM 作为 Windows 服务运行 |
|
||||||
|
| Nginx | 通过 NSSM 作为 Windows 服务运行 |
|
||||||
|
| SSL 证书 | 使用 Certbot (Chocolatey 安装) |
|
||||||
|
| 定时任务 | 使用 Windows 任务计划程序 |
|
||||||
|
| 日志查看 | `C:\pocketbase\logs\` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🐳 方式三:Docker 容器化部署
|
||||||
|
|
||||||
|
### 前置要求
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 安装 Docker & Docker Compose
|
||||||
|
curl -fsSL https://get.docker.com | bash
|
||||||
|
systemctl enable docker --now
|
||||||
|
|
||||||
|
# 安装 Docker Compose v2
|
||||||
|
docker compose version
|
||||||
|
```
|
||||||
|
|
||||||
|
### 快速启动
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 进入 docker 目录
|
||||||
|
cd docker
|
||||||
|
|
||||||
|
# 2. 复制环境变量模板
|
||||||
|
cp .env.example .env
|
||||||
|
|
||||||
|
# 3. 编辑配置
|
||||||
|
vim .env
|
||||||
|
```
|
||||||
|
|
||||||
|
**`.env` 关键配置:**
|
||||||
|
|
||||||
|
```env
|
||||||
|
# 域名配置
|
||||||
|
DOMAIN=your-domain.com
|
||||||
|
EMAIL=admin@your-domain.com
|
||||||
|
|
||||||
|
# PocketBase 配置
|
||||||
|
PB_ENCRYPTION_KEY=your-32-char-base64-key
|
||||||
|
PB_ADMIN_EMAIL=admin@yunsheng.digital
|
||||||
|
PB_ADMIN_PASSWORD=YourStrongPassword123!
|
||||||
|
|
||||||
|
# 时区
|
||||||
|
TZ=Asia/Shanghai
|
||||||
|
```
|
||||||
|
|
||||||
|
### 生成加密密钥
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 生成 32 字节 Base64 密钥
|
||||||
|
openssl rand -base64 32
|
||||||
|
```
|
||||||
|
|
||||||
|
### 启动服务
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 构建并启动
|
||||||
|
docker compose up -d --build
|
||||||
|
|
||||||
|
# 查看日志
|
||||||
|
docker compose logs -f
|
||||||
|
|
||||||
|
# 查看状态
|
||||||
|
docker compose ps
|
||||||
|
```
|
||||||
|
|
||||||
|
### Docker Compose 服务说明
|
||||||
|
|
||||||
|
| 服务 | 端口 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| pocketbase | 8090 | 后端 API + Admin UI + 静态文件 |
|
||||||
|
| nginx | 80/443 | 反向代理 + SSL 终止 + 静态文件缓存 |
|
||||||
|
| certbot | - | 自动申请/续期 Let's Encrypt 证书 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🌐 域名与 SSL 配置
|
||||||
|
|
||||||
|
### 域名解析
|
||||||
|
|
||||||
|
在域名服务商控制台添加记录:
|
||||||
|
|
||||||
|
| 记录类型 | 主机记录 | 记录值 | TTL |
|
||||||
|
|----------|----------|--------|-----|
|
||||||
|
| A | @ | 你的服务器公网 IP | 600 |
|
||||||
|
| A | www | 你的服务器公网 IP | 600 |
|
||||||
|
|
||||||
|
> ⏳ 解析生效通常需要 1-10 分钟,可用 `dig your-domain.com` 验证
|
||||||
|
|
||||||
|
### SSL 证书
|
||||||
|
|
||||||
|
**自动模式(推荐):** 一键脚本 / Docker 会自动申请 Let's Encrypt 证书并配置自动续期。
|
||||||
|
|
||||||
|
**手动申请:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Linux (Certbot + Nginx)
|
||||||
|
sudo certbot --nginx -d your-domain.com -d www.your-domain.com
|
||||||
|
|
||||||
|
# 仅申请证书 (不修改 Nginx 配置)
|
||||||
|
sudo certbot certonly --nginx -d your-domain.com -d www.your-domain.com
|
||||||
|
|
||||||
|
# 测试自动续期
|
||||||
|
sudo certbot renew --dry-run
|
||||||
|
```
|
||||||
|
|
||||||
|
**证书路径:**
|
||||||
|
|
||||||
|
- Linux: `/etc/letsencrypt/live/your-domain.com/`
|
||||||
|
- Windows: `C:\Certbot\live\your-domain.com\`
|
||||||
|
- Docker: `/etc/letsencrypt/live/your-domain.com/` (容器内)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔧 后台初始化
|
||||||
|
|
||||||
|
### 首次登录
|
||||||
|
|
||||||
|
1. 访问 `https://your-domain.com/_/`
|
||||||
|
2. 使用默认账号登录:
|
||||||
|
- **邮箱**: `admin@yunsheng.digital`
|
||||||
|
- **密码**: `YunSheng@2024!Admin`
|
||||||
|
|
||||||
|
### 必做安全设置
|
||||||
|
|
||||||
|
⚠️ **请立即完成以下操作:**
|
||||||
|
|
||||||
|
1. **修改管理员密码**
|
||||||
|
- 点击右上角头像 → Profile → Change Password
|
||||||
|
|
||||||
|
2. **修改加密密钥备份**
|
||||||
|
- Linux: `cat /etc/systemd/system/pocketbase.service | grep PB_ENCRYPTION_KEY`
|
||||||
|
- 保存到安全位置(迁移服务器必需)
|
||||||
|
|
||||||
|
3. **配置站点基本信息**
|
||||||
|
- Settings → Site Settings → 填写站点名称、描述、Logo、ICP 备案号等
|
||||||
|
|
||||||
|
4. **启用背景音乐(可选)**
|
||||||
|
- Settings → Background Music → 上传音频文件或填写外链
|
||||||
|
|
||||||
|
5. **创建分类与标签**
|
||||||
|
- Collections → categories / tags → New Record
|
||||||
|
|
||||||
|
6. **发布第一篇文章**
|
||||||
|
- Collections → posts → New Record → 使用 TipTap 编辑器撰写
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🛠️ 常用维护命令
|
||||||
|
|
||||||
|
### Linux (systemd)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 服务管理
|
||||||
|
systemctl status pocketbase # 查看状态
|
||||||
|
systemctl restart pocketbase # 重启
|
||||||
|
systemctl stop pocketbase # 停止
|
||||||
|
systemctl start pocketbase # 启动
|
||||||
|
|
||||||
|
# Nginx
|
||||||
|
systemctl status nginx
|
||||||
|
systemctl reload nginx # 重载配置 (无中断)
|
||||||
|
nginx -t # 测试配置
|
||||||
|
|
||||||
|
# 查看日志
|
||||||
|
journalctl -u pocketbase -f # 实时日志
|
||||||
|
journalctl -u pocketbase -n 100 # 最近 100 行
|
||||||
|
tail -f /var/log/nginx/access.log
|
||||||
|
tail -f /var/log/nginx/error.log
|
||||||
|
|
||||||
|
# 备份
|
||||||
|
/usr/local/bin/yunsheng-backup
|
||||||
|
|
||||||
|
# 更新
|
||||||
|
/usr/local/bin/yunsheng-update
|
||||||
|
|
||||||
|
# 状态检查
|
||||||
|
/usr/local/bin/yunsheng-status
|
||||||
|
```
|
||||||
|
|
||||||
|
### Windows (PowerShell)
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# 服务管理
|
||||||
|
net start PocketBase
|
||||||
|
net stop PocketBase
|
||||||
|
Restart-Service PocketBase
|
||||||
|
|
||||||
|
net start nginx
|
||||||
|
net stop nginx
|
||||||
|
|
||||||
|
# 查看日志
|
||||||
|
Get-Content C:\pocketbase\logs\stdout.log -Wait
|
||||||
|
Get-Content C:\nginx\logs\error.log -Wait
|
||||||
|
|
||||||
|
# 备份
|
||||||
|
C:\pocketbase\scripts\backup.bat
|
||||||
|
|
||||||
|
# 更新
|
||||||
|
C:\pocketbase\scripts\update.bat
|
||||||
|
|
||||||
|
# 状态
|
||||||
|
C:\pocketbase\scripts\status.bat
|
||||||
|
```
|
||||||
|
|
||||||
|
### Docker
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 服务管理
|
||||||
|
docker compose ps
|
||||||
|
docker compose restart pocketbase
|
||||||
|
docker compose restart nginx
|
||||||
|
|
||||||
|
# 查看日志
|
||||||
|
docker compose logs -f pocketbase
|
||||||
|
docker compose logs -f nginx
|
||||||
|
|
||||||
|
# 备份 (进入容器执行)
|
||||||
|
docker compose exec pocketbase pocketbase dump --dir=/pb_data --output=/backup/pb_$(date +%Y%m%d).zip
|
||||||
|
|
||||||
|
# 更新镜像
|
||||||
|
docker compose pull
|
||||||
|
docker compose up -d --build
|
||||||
|
|
||||||
|
# 进入容器
|
||||||
|
docker compose exec pocketbase sh
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔍 故障排查
|
||||||
|
|
||||||
|
### 1. 网站无法访问 (502 Bad Gateway)
|
||||||
|
|
||||||
|
**原因**:PocketBase 服务未运行
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 检查服务状态
|
||||||
|
systemctl status pocketbase
|
||||||
|
|
||||||
|
# 查看错误日志
|
||||||
|
journalctl -u pocketbase -n 50
|
||||||
|
|
||||||
|
# 常见解决:
|
||||||
|
# - 端口 8090 被占用: ss -tlnp | grep 8090
|
||||||
|
# - 权限问题: chown -R www-data:www-data /opt/pocketbase
|
||||||
|
# - 加密密钥错误: 检查 systemd 服务文件中的 PB_ENCRYPTION_KEY
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 静态资源 404 (CSS/JS/图片加载失败)
|
||||||
|
|
||||||
|
**原因**:前端构建产物未部署或 Nginx root 路径错误
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 检查构建产物
|
||||||
|
ls -la /opt/pocketbase/pb_public/
|
||||||
|
|
||||||
|
# 应该看到 index.html、assets/ 目录
|
||||||
|
# 如果为空,重新构建前端:
|
||||||
|
cd /path/to/project/apps/web
|
||||||
|
pnpm build
|
||||||
|
cp -r dist/* /opt/pocketbase/pb_public/
|
||||||
|
|
||||||
|
# 检查 Nginx 配置 root 路径
|
||||||
|
grep -n "root" /etc/nginx/sites-enabled/yunsheng-digital
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. SSL 证书申请失败
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 检查域名解析
|
||||||
|
dig your-domain.com +short
|
||||||
|
# 必须返回服务器公网 IP
|
||||||
|
|
||||||
|
# 检查 80 端口可达性
|
||||||
|
curl -I http://your-domain.com/.well-known/acme-challenge/test
|
||||||
|
|
||||||
|
# 手动申请 (调试模式)
|
||||||
|
certbot certonly --nginx -d your-domain.com -v --dry-run
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 文件上传失败 / 体积过大
|
||||||
|
|
||||||
|
**修改上传限制:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. PocketBase 启动参数 (systemd 服务文件)
|
||||||
|
ExecStart=... --maxUploadSize=104857600 # 100MB
|
||||||
|
|
||||||
|
# 2. Nginx 配置
|
||||||
|
client_max_body_size 100M;
|
||||||
|
|
||||||
|
# 3. 重启生效
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl restart pocketbase nginx
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. 数据库损坏 / 锁定
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 停止服务
|
||||||
|
systemctl stop pocketbase
|
||||||
|
|
||||||
|
# 备份当前数据
|
||||||
|
cp -r /opt/pocketbase/pb_data /opt/pocketbase/pb_data.backup.$(date +%Y%m%d)
|
||||||
|
|
||||||
|
# 尝试修复 (PocketBase 内置)
|
||||||
|
/opt/pocketbase/pocketbase migrate --dir=/opt/pocketbase/pb_data --migrationsDir=/opt/pocketbase/pb_migrations
|
||||||
|
|
||||||
|
# 或从备份恢复
|
||||||
|
/opt/pocketbase/pocketbase restore --dir=/opt/pocketbase/pb_data --input=/backup/pb_backup.zip
|
||||||
|
|
||||||
|
# 重启
|
||||||
|
systemctl start pocketbase
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. 内存不足 (OOM Killer)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 检查内存
|
||||||
|
free -h
|
||||||
|
|
||||||
|
# 添加 Swap (临时缓解)
|
||||||
|
fallocate -l 2G /swapfile
|
||||||
|
chmod 600 /swapfile
|
||||||
|
mkswap /swapfile
|
||||||
|
swapon /swapfile
|
||||||
|
echo '/swapfile none swap sw 0 0' >> /etc/fstab
|
||||||
|
|
||||||
|
# 限制 PocketBase 内存 (systemd)
|
||||||
|
MemoryLimit=512M
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚙️ 进阶配置
|
||||||
|
|
||||||
|
### 1. 配置 CDN (Cloudflare / 阿里云 CDN / 腾讯云 CDN)
|
||||||
|
|
||||||
|
**优势**:隐藏源站 IP、DDoS 防护、静态资源加速
|
||||||
|
|
||||||
|
**关键设置**:
|
||||||
|
|
||||||
|
```
|
||||||
|
# Cloudflare 示例
|
||||||
|
SSL/TLS 模式: Full (Strict)
|
||||||
|
开启: Automatic HTTPS Rewrites, Always Use HTTPS
|
||||||
|
缓存规则:
|
||||||
|
- 静态资源 (*.js, *.css, *.png, *.jpg 等) -> Cache Everything, Edge TTL: 1 年
|
||||||
|
- HTML 页面 -> Bypass Cache (或短 TTL)
|
||||||
|
- /api/* -> Bypass Cache
|
||||||
|
- /_/ * -> Bypass Cache (Admin UI)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Nginx 配合 CDN 获取真实 IP**:
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
# 在 http 块中添加
|
||||||
|
set_real_ip_from 103.21.244.0/22; # Cloudflare IP 段
|
||||||
|
set_real_ip_from 103.22.200.0/22;
|
||||||
|
# ... 更多 Cloudflare IP
|
||||||
|
real_ip_header CF-Connecting-IP;
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 开启 HTTP/3 (QUIC)
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
# Nginx 1.25+ 支持
|
||||||
|
server {
|
||||||
|
listen 443 quic reuseport;
|
||||||
|
listen 443 ssl http2;
|
||||||
|
# ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 配置对象存储 (S3 兼容) 存储上传文件
|
||||||
|
|
||||||
|
**PocketBase 支持 S3 存储后端**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 启动参数添加
|
||||||
|
--s3Endpoint=https://s3.your-provider.com \
|
||||||
|
--s3Bucket=your-bucket \
|
||||||
|
--s3AccessKey=YOUR_ACCESS_KEY \
|
||||||
|
--s3SecretKey=YOUR_SECRET_KEY \
|
||||||
|
--s3Region=auto
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 迁移到 PostgreSQL (高并发场景)
|
||||||
|
|
||||||
|
PocketBase v0.22+ 实验性支持 PostgreSQL:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 启动参数
|
||||||
|
--dsn="postgres://user:pass@host:5432/dbname?sslmode=require"
|
||||||
|
```
|
||||||
|
|
||||||
|
> ⚠️ 迁移需谨慎,建议先测试环境验证
|
||||||
|
|
||||||
|
### 5. 多服务器负载均衡
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────┐
|
||||||
|
用户 ───→ CDN/WAF ───→ Nginx (LB) ───→ PocketBase #1
|
||||||
|
│ └─────────────┘
|
||||||
|
│ ┌─────────────┐
|
||||||
|
└──────────────→ PocketBase #2
|
||||||
|
└─────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**注意**:
|
||||||
|
- PocketBase 需要共享同一数据库 (PostgreSQL)
|
||||||
|
- 实时订阅 需要 Redis 适配器 (开发中)
|
||||||
|
- 静态文件需共享存储 (NFS/S3)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📦 更新日志
|
||||||
|
|
||||||
|
| 版本 | 日期 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| v1.0.0 | 2024-01-15 | 初始版本发布 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🤝 获取帮助
|
||||||
|
|
||||||
|
- **GitHub Issues**: [提交问题](https://git.grxiao.cn/yuns/plerr-open/issues)
|
||||||
|
- **文档中心**: [docs.yunsheng.digital](https://docs.yunsheng.digital)
|
||||||
|
- **邮件支持**: tech@yunsheng.digital
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📄 许可证
|
||||||
|
|
||||||
|
MIT License - 详见 [LICENSE](../LICENSE)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
> 💡 **提示**:部署遇到问题?请先查看 [故障排查](#故障排查) 章节,大多数问题都能自行解决。如果仍无法解决,请提供完整的错误日志和服务器环境信息提交 Issue。
|
||||||
@@ -0,0 +1,605 @@
|
|||||||
|
# 云升数码 - 部署教程
|
||||||
|
|
||||||
|
> 一文搞定从零到线上的完整部署流程,支持 Linux / Windows / Docker 三种方式
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📋 目录
|
||||||
|
|
||||||
|
- [准备工作](#准备工作)
|
||||||
|
- [方式一:Linux 一键部署(推荐)](#方式一linux-一键部署推荐)
|
||||||
|
- [方式二:Windows 部署](#方式二windows-部署)
|
||||||
|
- [方式三:Docker 容器化部署](#方式三docker-容器化部署)
|
||||||
|
- [域名与 SSL 配置](#域名与-ssl-配置)
|
||||||
|
- [后台初始化](#后台初始化)
|
||||||
|
- [常用维护命令](#常用维护命令)
|
||||||
|
- [故障排查](#故障排查)
|
||||||
|
- [进阶配置](#进阶配置)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🎯 准备工作
|
||||||
|
|
||||||
|
### 服务器要求
|
||||||
|
|
||||||
|
| 组件 | 最低配置 | 推荐配置 |
|
||||||
|
|------|----------|----------|
|
||||||
|
| CPU | 1 核 | 2 核+ |
|
||||||
|
| 内存 | 1 GB | 2 GB+ |
|
||||||
|
| 硬盘 | 10 GB | 50 GB+ (SSD) |
|
||||||
|
| 系统 | Ubuntu 22.04+ / Debian 12+ / CentOS 9+ / Windows Server 2019+ | Ubuntu 24.04 LTS |
|
||||||
|
| 带宽 | 5 Mbps | 20 Mbps+ |
|
||||||
|
|
||||||
|
### 必备条件
|
||||||
|
|
||||||
|
- ✅ 一台云服务器(腾讯云/阿里云/华为云/轻量应用服务器均可)
|
||||||
|
- ✅ 一个已备案域名(中国大陆节点必须备案)
|
||||||
|
- ✅ 域名已解析到服务器公网 IP(A 记录)
|
||||||
|
- ✅ SSH 客户端(Windows 推荐 Terminal / Tabby,Mac/Linux 自带 Terminal)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🚀 方式一:Linux 一键部署(推荐)
|
||||||
|
|
||||||
|
### 适用系统
|
||||||
|
|
||||||
|
- Ubuntu 22.04 / 24.04 LTS
|
||||||
|
- Debian 12 (Bookworm)
|
||||||
|
- CentOS / Rocky Linux / AlmaLinux 9+
|
||||||
|
|
||||||
|
### 步骤 1:连接服务器
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 使用 SSH 连接(替换为你的服务器 IP)
|
||||||
|
ssh root@your-server-ip
|
||||||
|
|
||||||
|
# 或使用密钥登录
|
||||||
|
ssh -i ~/.ssh/id_rsa root@your-server-ip
|
||||||
|
```
|
||||||
|
|
||||||
|
### 步骤 2:下载并运行安装脚本
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 克隆项目仓库
|
||||||
|
git clone https://git.grxiao.cn/yuns/plerr-open.git
|
||||||
|
cd plerr-open
|
||||||
|
|
||||||
|
# 2. 赋予脚本执行权限
|
||||||
|
chmod +x scripts/install.sh
|
||||||
|
|
||||||
|
# 3. 运行一键安装(需要 root 权限)
|
||||||
|
sudo ./scripts/install.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
### 步骤 3:按提示输入信息
|
||||||
|
|
||||||
|
脚本会交互式询问:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
请输入域名 (例: yunsheng.digital): your-domain.com
|
||||||
|
请输入邮箱 (用于 SSL 证书): admin@your-domain.com
|
||||||
|
```
|
||||||
|
|
||||||
|
> 💡 **提示**:如果不想交互,可直接传参:
|
||||||
|
> ```bash
|
||||||
|
> sudo ./scripts/install.sh your-domain.com admin@your-domain.com
|
||||||
|
> ```
|
||||||
|
|
||||||
|
### 步骤 4:等待部署完成
|
||||||
|
|
||||||
|
脚本会自动完成以下工作(约 3-5 分钟):
|
||||||
|
|
||||||
|
1. 📦 安装系统依赖
|
||||||
|
2. ⬇️ 下载 PocketBase v0.22.0
|
||||||
|
3. ⚙️ 配置 systemd 服务
|
||||||
|
4. 🌐 配置 Nginx 反向代理
|
||||||
|
5. 🔒 申请 Let's Encrypt SSL 证书
|
||||||
|
6. 🛡️ 配置防火墙 & Fail2Ban
|
||||||
|
7. 📝 创建维护脚本 & 定时任务
|
||||||
|
8. 🚀 启动所有服务
|
||||||
|
|
||||||
|
### 步骤 5:访问验证
|
||||||
|
|
||||||
|
部署成功后会显示:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
==========================================
|
||||||
|
部署完成!
|
||||||
|
==========================================
|
||||||
|
|
||||||
|
📋 重要信息:
|
||||||
|
- 网站地址: https://your-domain.com
|
||||||
|
- 后台管理: https://your-domain.com/_/
|
||||||
|
- 管理员账号: admin@yunsheng.digital
|
||||||
|
- 管理员密码: YunSheng@2024!Admin
|
||||||
|
- PocketBase 数据目录: /opt/pocketbase/pb_data
|
||||||
|
- 前端构建目录: /opt/pocketbase/pb_public
|
||||||
|
```
|
||||||
|
|
||||||
|
立即访问 `https://your-domain.com` 查看网站,访问 `https://your-domain.com/_/` 进入后台管理。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🪟 方式二:Windows 部署
|
||||||
|
|
||||||
|
### 适用系统
|
||||||
|
|
||||||
|
- Windows 10/11 (专业版/企业版)
|
||||||
|
- Windows Server 2019/2022
|
||||||
|
|
||||||
|
### 前置要求
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# 1. 以管理员身份打开 PowerShell
|
||||||
|
# 2. 启用脚本执行策略
|
||||||
|
Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser
|
||||||
|
|
||||||
|
# 3. 安装 Chocolatey (如果未安装)
|
||||||
|
Set-ExecutionPolicy Bypass -Scope Process -Force
|
||||||
|
[System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor 3072
|
||||||
|
iex ((New-Object System.Net.WebClient).DownloadString('https://community.chocolatey.org/install.ps1'))
|
||||||
|
refreshenv
|
||||||
|
```
|
||||||
|
|
||||||
|
### 运行安装脚本
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# 1. 克隆仓库
|
||||||
|
git clone https://git.grxiao.cn/yuns/plerr-open.git
|
||||||
|
cd plerr-open
|
||||||
|
|
||||||
|
# 2. 运行安装脚本 (管理员 PowerShell)
|
||||||
|
.\scripts\install.ps1
|
||||||
|
|
||||||
|
# 或传参
|
||||||
|
.\scripts\install.ps1 -Domain "your-domain.com" -Email "admin@your-domain.com"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Windows 特有说明
|
||||||
|
|
||||||
|
| 组件 | 说明 |
|
||||||
|
|------|------|
|
||||||
|
| PocketBase | 通过 NSSM 作为 Windows 服务运行 |
|
||||||
|
| Nginx | 通过 NSSM 作为 Windows 服务运行 |
|
||||||
|
| SSL 证书 | 使用 Certbot (Chocolatey 安装) |
|
||||||
|
| 定时任务 | 使用 Windows 任务计划程序 |
|
||||||
|
| 日志查看 | `C:\pocketbase\logs\` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🐳 方式三:Docker 容器化部署
|
||||||
|
|
||||||
|
### 前置要求
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 安装 Docker & Docker Compose
|
||||||
|
curl -fsSL https://get.docker.com | bash
|
||||||
|
systemctl enable docker --now
|
||||||
|
|
||||||
|
# 安装 Docker Compose v2
|
||||||
|
docker compose version
|
||||||
|
```
|
||||||
|
|
||||||
|
### 快速启动
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. 进入 docker 目录
|
||||||
|
cd docker
|
||||||
|
|
||||||
|
# 2. 复制环境变量模板
|
||||||
|
cp .env.example .env
|
||||||
|
|
||||||
|
# 3. 编辑配置
|
||||||
|
vim .env
|
||||||
|
```
|
||||||
|
|
||||||
|
**`.env` 关键配置:**
|
||||||
|
|
||||||
|
```env
|
||||||
|
# 域名配置
|
||||||
|
DOMAIN=your-domain.com
|
||||||
|
EMAIL=admin@your-domain.com
|
||||||
|
|
||||||
|
# PocketBase 配置
|
||||||
|
PB_ENCRYPTION_KEY=your-32-char-base64-key
|
||||||
|
PB_ADMIN_EMAIL=admin@yunsheng.digital
|
||||||
|
PB_ADMIN_PASSWORD=YourStrongPassword123!
|
||||||
|
|
||||||
|
# 时区
|
||||||
|
TZ=Asia/Shanghai
|
||||||
|
```
|
||||||
|
|
||||||
|
### 生成加密密钥
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 生成 32 字节 Base64 密钥
|
||||||
|
openssl rand -base64 32
|
||||||
|
```
|
||||||
|
|
||||||
|
### 启动服务
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 构建并启动
|
||||||
|
docker compose up -d --build
|
||||||
|
|
||||||
|
# 查看日志
|
||||||
|
docker compose logs -f
|
||||||
|
|
||||||
|
# 查看状态
|
||||||
|
docker compose ps
|
||||||
|
```
|
||||||
|
|
||||||
|
### Docker Compose 服务说明
|
||||||
|
|
||||||
|
| 服务 | 端口 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| pocketbase | 8090 | 后端 API + Admin UI + 静态文件 |
|
||||||
|
| nginx | 80/443 | 反向代理 + SSL 终止 + 静态文件缓存 |
|
||||||
|
| certbot | - | 自动申请/续期 Let's Encrypt 证书 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🌐 域名与 SSL 配置
|
||||||
|
|
||||||
|
### 域名解析
|
||||||
|
|
||||||
|
在域名服务商控制台添加记录:
|
||||||
|
|
||||||
|
| 记录类型 | 主机记录 | 记录值 | TTL |
|
||||||
|
|----------|----------|--------|-----|
|
||||||
|
| A | @ | 你的服务器公网 IP | 600 |
|
||||||
|
| A | www | 你的服务器公网 IP | 600 |
|
||||||
|
|
||||||
|
> ⏳ 解析生效通常需要 1-10 分钟,可用 `dig your-domain.com` 验证
|
||||||
|
|
||||||
|
### SSL 证书
|
||||||
|
|
||||||
|
**自动模式(推荐):** 一键脚本 / Docker 会自动申请 Let's Encrypt 证书并配置自动续期。
|
||||||
|
|
||||||
|
**手动申请:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Linux (Certbot + Nginx)
|
||||||
|
sudo certbot --nginx -d your-domain.com -d www.your-domain.com
|
||||||
|
|
||||||
|
# 仅申请证书 (不修改 Nginx 配置)
|
||||||
|
sudo certbot certonly --nginx -d your-domain.com -d www.your-domain.com
|
||||||
|
|
||||||
|
# 测试自动续期
|
||||||
|
sudo certbot renew --dry-run
|
||||||
|
```
|
||||||
|
|
||||||
|
**证书路径:**
|
||||||
|
|
||||||
|
- Linux: `/etc/letsencrypt/live/your-domain.com/`
|
||||||
|
- Windows: `C:\Certbot\live\your-domain.com\`
|
||||||
|
- Docker: `/etc/letsencrypt/live/your-domain.com/` (容器内)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔧 后台初始化
|
||||||
|
|
||||||
|
### 首次登录
|
||||||
|
|
||||||
|
1. 访问 `https://your-domain.com/_/`
|
||||||
|
2. 使用默认账号登录:
|
||||||
|
- **邮箱**: `admin@yunsheng.digital`
|
||||||
|
- **密码**: `YunSheng@2024!Admin`
|
||||||
|
|
||||||
|
### 必做安全设置
|
||||||
|
|
||||||
|
⚠️ **请立即完成以下操作:**
|
||||||
|
|
||||||
|
1. **修改管理员密码**
|
||||||
|
- 点击右上角头像 → Profile → Change Password
|
||||||
|
|
||||||
|
2. **修改加密密钥备份**
|
||||||
|
- Linux: `cat /etc/systemd/system/pocketbase.service | grep PB_ENCRYPTION_KEY`
|
||||||
|
- 保存到安全位置(迁移服务器必需)
|
||||||
|
|
||||||
|
3. **配置站点基本信息**
|
||||||
|
- Settings → Site Settings → 填写站点名称、描述、Logo、ICP 备案号等
|
||||||
|
|
||||||
|
4. **启用背景音乐(可选)**
|
||||||
|
- Settings → Background Music → 上传音频文件或填写外链
|
||||||
|
|
||||||
|
5. **创建分类与标签**
|
||||||
|
- Collections → categories / tags → New Record
|
||||||
|
|
||||||
|
6. **发布第一篇文章**
|
||||||
|
- Collections → posts → New Record → 使用 TipTap 编辑器撰写
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🛠️ 常用维护命令
|
||||||
|
|
||||||
|
### Linux (systemd)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 服务管理
|
||||||
|
systemctl status pocketbase # 查看状态
|
||||||
|
systemctl restart pocketbase # 重启
|
||||||
|
systemctl stop pocketbase # 停止
|
||||||
|
systemctl start pocketbase # 启动
|
||||||
|
|
||||||
|
# Nginx
|
||||||
|
systemctl status nginx
|
||||||
|
systemctl reload nginx # 重载配置 (无中断)
|
||||||
|
nginx -t # 测试配置
|
||||||
|
|
||||||
|
# 查看日志
|
||||||
|
journalctl -u pocketbase -f # 实时日志
|
||||||
|
journalctl -u pocketbase -n 100 # 最近 100 行
|
||||||
|
tail -f /var/log/nginx/access.log
|
||||||
|
tail -f /var/log/nginx/error.log
|
||||||
|
|
||||||
|
# 备份
|
||||||
|
/usr/local/bin/yunsheng-backup
|
||||||
|
|
||||||
|
# 更新
|
||||||
|
/usr/local/bin/yunsheng-update
|
||||||
|
|
||||||
|
# 状态检查
|
||||||
|
/usr/local/bin/yunsheng-status
|
||||||
|
```
|
||||||
|
|
||||||
|
### Windows (PowerShell)
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
# 服务管理
|
||||||
|
net start PocketBase
|
||||||
|
net stop PocketBase
|
||||||
|
Restart-Service PocketBase
|
||||||
|
|
||||||
|
net start nginx
|
||||||
|
net stop nginx
|
||||||
|
|
||||||
|
# 查看日志
|
||||||
|
Get-Content C:\pocketbase\logs\stdout.log -Wait
|
||||||
|
Get-Content C:\nginx\logs\error.log -Wait
|
||||||
|
|
||||||
|
# 备份
|
||||||
|
C:\pocketbase\scripts\backup.bat
|
||||||
|
|
||||||
|
# 更新
|
||||||
|
C:\pocketbase\scripts\update.bat
|
||||||
|
|
||||||
|
# 状态
|
||||||
|
C:\pocketbase\scripts\status.bat
|
||||||
|
```
|
||||||
|
|
||||||
|
### Docker
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 服务管理
|
||||||
|
docker compose ps
|
||||||
|
docker compose restart pocketbase
|
||||||
|
docker compose restart nginx
|
||||||
|
|
||||||
|
# 查看日志
|
||||||
|
docker compose logs -f pocketbase
|
||||||
|
docker compose logs -f nginx
|
||||||
|
|
||||||
|
# 备份 (进入容器执行)
|
||||||
|
docker compose exec pocketbase pocketbase dump --dir=/pb_data --output=/backup/pb_$(date +%Y%m%d).zip
|
||||||
|
|
||||||
|
# 更新镜像
|
||||||
|
docker compose pull
|
||||||
|
docker compose up -d --build
|
||||||
|
|
||||||
|
# 进入容器
|
||||||
|
docker compose exec pocketbase sh
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🔍 故障排查
|
||||||
|
|
||||||
|
### 1. 网站无法访问 (502 Bad Gateway)
|
||||||
|
|
||||||
|
**原因**:PocketBase 服务未运行
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 检查服务状态
|
||||||
|
systemctl status pocketbase
|
||||||
|
|
||||||
|
# 查看错误日志
|
||||||
|
journalctl -u pocketbase -n 50
|
||||||
|
|
||||||
|
# 常见解决:
|
||||||
|
# - 端口 8090 被占用: ss -tlnp | grep 8090
|
||||||
|
# - 权限问题: chown -R www-data:www-data /opt/pocketbase
|
||||||
|
# - 加密密钥错误: 检查 systemd 服务文件中的 PB_ENCRYPTION_KEY
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 静态资源 404 (CSS/JS/图片加载失败)
|
||||||
|
|
||||||
|
**原因**:前端构建产物未部署或 Nginx root 路径错误
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 检查构建产物
|
||||||
|
ls -la /opt/pocketbase/pb_public/
|
||||||
|
|
||||||
|
# 应该看到 index.html、assets/ 目录
|
||||||
|
# 如果为空,重新构建前端:
|
||||||
|
cd /path/to/project/apps/web
|
||||||
|
pnpm build
|
||||||
|
cp -r dist/* /opt/pocketbase/pb_public/
|
||||||
|
|
||||||
|
# 检查 Nginx 配置 root 路径
|
||||||
|
grep -n "root" /etc/nginx/sites-enabled/yunsheng-digital
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. SSL 证书申请失败
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 检查域名解析
|
||||||
|
dig your-domain.com +short
|
||||||
|
# 必须返回服务器公网 IP
|
||||||
|
|
||||||
|
# 检查 80 端口可达性
|
||||||
|
curl -I http://your-domain.com/.well-known/acme-challenge/test
|
||||||
|
|
||||||
|
# 手动申请 (调试模式)
|
||||||
|
certbot certonly --nginx -d your-domain.com -v --dry-run
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 文件上传失败 / 体积过大
|
||||||
|
|
||||||
|
**修改上传限制:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. PocketBase 启动参数 (systemd 服务文件)
|
||||||
|
ExecStart=... --maxUploadSize=104857600 # 100MB
|
||||||
|
|
||||||
|
# 2. Nginx 配置
|
||||||
|
client_max_body_size 100M;
|
||||||
|
|
||||||
|
# 3. 重启生效
|
||||||
|
systemctl daemon-reload
|
||||||
|
systemctl restart pocketbase nginx
|
||||||
|
```
|
||||||
|
|
||||||
|
### 5. 数据库损坏 / 锁定
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 停止服务
|
||||||
|
systemctl stop pocketbase
|
||||||
|
|
||||||
|
# 备份当前数据
|
||||||
|
cp -r /opt/pocketbase/pb_data /opt/pocketbase/pb_data.backup.$(date +%Y%m%d)
|
||||||
|
|
||||||
|
# 尝试修复 (PocketBase 内置)
|
||||||
|
/opt/pocketbase/pocketbase migrate --dir=/opt/pocketbase/pb_data --migrationsDir=/opt/pocketbase/pb_migrations
|
||||||
|
|
||||||
|
# 或从备份恢复
|
||||||
|
/opt/pocketbase/pocketbase restore --dir=/opt/pocketbase/pb_data --input=/backup/pb_backup.zip
|
||||||
|
|
||||||
|
# 重启
|
||||||
|
systemctl start pocketbase
|
||||||
|
```
|
||||||
|
|
||||||
|
### 6. 内存不足 (OOM Killer)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 检查内存
|
||||||
|
free -h
|
||||||
|
|
||||||
|
# 添加 Swap (临时缓解)
|
||||||
|
fallocate -l 2G /swapfile
|
||||||
|
chmod 600 /swapfile
|
||||||
|
mkswap /swapfile
|
||||||
|
swapon /swapfile
|
||||||
|
echo '/swapfile none swap sw 0 0' >> /etc/fstab
|
||||||
|
|
||||||
|
# 限制 PocketBase 内存 (systemd)
|
||||||
|
MemoryLimit=512M
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## ⚙️ 进阶配置
|
||||||
|
|
||||||
|
### 1. 配置 CDN (Cloudflare / 阿里云 CDN / 腾讯云 CDN)
|
||||||
|
|
||||||
|
**优势**:隐藏源站 IP、DDoS 防护、静态资源加速
|
||||||
|
|
||||||
|
**关键设置**:
|
||||||
|
|
||||||
|
```
|
||||||
|
# Cloudflare 示例
|
||||||
|
SSL/TLS 模式: Full (Strict)
|
||||||
|
开启: Automatic HTTPS Rewrites, Always Use HTTPS
|
||||||
|
缓存规则:
|
||||||
|
- 静态资源 (*.js, *.css, *.png, *.jpg 等) -> Cache Everything, Edge TTL: 1 年
|
||||||
|
- HTML 页面 -> Bypass Cache (或短 TTL)
|
||||||
|
- /api/* -> Bypass Cache
|
||||||
|
- /_/ * -> Bypass Cache (Admin UI)
|
||||||
|
```
|
||||||
|
|
||||||
|
**Nginx 配合 CDN 获取真实 IP**:
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
# 在 http 块中添加
|
||||||
|
set_real_ip_from 103.21.244.0/22; # Cloudflare IP 段
|
||||||
|
set_real_ip_from 103.22.200.0/22;
|
||||||
|
# ... 更多 Cloudflare IP
|
||||||
|
real_ip_header CF-Connecting-IP;
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. 开启 HTTP/3 (QUIC)
|
||||||
|
|
||||||
|
```nginx
|
||||||
|
# Nginx 1.25+ 支持
|
||||||
|
server {
|
||||||
|
listen 443 quic reuseport;
|
||||||
|
listen 443 ssl http2;
|
||||||
|
# ...
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. 配置对象存储 (S3 兼容) 存储上传文件
|
||||||
|
|
||||||
|
**PocketBase 支持 S3 存储后端**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 启动参数添加
|
||||||
|
--s3Endpoint=https://s3.your-provider.com \
|
||||||
|
--s3Bucket=your-bucket \
|
||||||
|
--s3AccessKey=YOUR_ACCESS_KEY \
|
||||||
|
--s3SecretKey=YOUR_SECRET_KEY \
|
||||||
|
--s3Region=auto
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. 迁移到 PostgreSQL (高并发场景)
|
||||||
|
|
||||||
|
PocketBase v0.22+ 实验性支持 PostgreSQL:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 启动参数
|
||||||
|
--dsn="postgres://user:pass@host:5432/dbname?sslmode=require"
|
||||||
|
```
|
||||||
|
|
||||||
|
> ⚠️ 迁移需谨慎,建议先测试环境验证
|
||||||
|
|
||||||
|
### 5. 多服务器负载均衡
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────┐
|
||||||
|
用户 ───→ CDN/WAF ───→ Nginx (LB) ───→ PocketBase #1
|
||||||
|
│ └─────────────┘
|
||||||
|
│ ┌─────────────┐
|
||||||
|
└──────────────→ PocketBase #2
|
||||||
|
└─────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
**注意**:
|
||||||
|
- PocketBase 需要共享同一数据库 (PostgreSQL)
|
||||||
|
- 实时订阅 需要 Redis 适配器 (开发中)
|
||||||
|
- 静态文件需共享存储 (NFS/S3)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📦 更新日志
|
||||||
|
|
||||||
|
| 版本 | 日期 | 说明 |
|
||||||
|
|------|------|------|
|
||||||
|
| v1.0.0 | 2024-01-15 | 初始版本发布 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 🤝 获取帮助
|
||||||
|
|
||||||
|
- **GitHub Issues**: [提交问题](https://git.grxiao.cn/yuns/plerr-open/issues)
|
||||||
|
- **文档中心**: [docs.yunsheng.digital](https://docs.yunsheng.digital)
|
||||||
|
- **邮件支持**: tech@yunsheng.digital
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 📄 许可证
|
||||||
|
|
||||||
|
MIT License - 详见 [LICENSE](../LICENSE)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
> 💡 **提示**:部署遇到问题?请先查看 [故障排查](#故障排查) 章节,大多数问题都能自行解决。如果仍无法解决,请提供完整的错误日志和服务器环境信息提交 Issue。
|
||||||
Reference in New Issue
Block a user