Merge pull request #432 from nagisa77/feature/nuxt_optimization

Feature: nuxt optimization
This commit is contained in:
Tim
2025-08-08 14:22:53 +08:00
committed by GitHub
7 changed files with 179 additions and 160 deletions

View File

@@ -76,7 +76,9 @@ export default {
const router = useRouter() const router = useRouter()
const goToHome = () => { const goToHome = () => {
router.push('/') router.push('/').then(() => {
window.location.reload()
})
} }
const search = () => { const search = () => {
showSearch.value = true showSearch.value = true

View File

@@ -71,7 +71,7 @@
<div v-if="isLoadingCategory" class="menu-loading-container"> <div v-if="isLoadingCategory" class="menu-loading-container">
<l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch> <l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
</div> </div>
<div v-else v-for="c in categories" :key="c.id" class="section-item" @click="gotoCategory(c)"> <div v-else v-for="c in categoryData" :key="c.id" class="section-item" @click="gotoCategory(c)">
<template v-if="c.smallIcon || c.icon"> <template v-if="c.smallIcon || c.icon">
<img v-if="isImageIcon(c.smallIcon || c.icon)" :src="c.smallIcon || c.icon" class="section-item-icon" :alt="c.name" /> <img v-if="isImageIcon(c.smallIcon || c.icon)" :src="c.smallIcon || c.icon" class="section-item-icon" :alt="c.name" />
<i v-else :class="['section-item-icon', c.smallIcon || c.icon]"></i> <i v-else :class="['section-item-icon', c.smallIcon || c.icon]"></i>
@@ -93,7 +93,7 @@
<div v-if="isLoadingTag" class="menu-loading-container"> <div v-if="isLoadingTag" class="menu-loading-container">
<l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch> <l-hatch size="28" stroke="4" speed="3.5" color="var(--primary-color)"></l-hatch>
</div> </div>
<div v-else v-for="t in tags" :key="t.id" class="section-item" @click="gotoTag(t)"> <div v-else v-for="t in tagData" :key="t.id" class="section-item" @click="gotoTag(t)">
<img v-if="isImageIcon(t.smallIcon || t.icon)" :src="t.smallIcon || t.icon" class="section-item-icon" :alt="t.name" /> <img v-if="isImageIcon(t.smallIcon || t.icon)" :src="t.smallIcon || t.icon" class="section-item-icon" :alt="t.name" />
<i v-else class="section-item-icon fas fa-hashtag"></i> <i v-else class="section-item-icon fas fa-hashtag"></i>
<span class="section-item-text">{{ t.name }} <span class="section-item-text-count">x {{ t.count <span class="section-item-text">{{ t.name }} <span class="section-item-text-count">x {{ t.count
@@ -115,7 +115,7 @@
import { themeState, cycleTheme, ThemeMode } from '~/utils/theme' import { themeState, cycleTheme, ThemeMode } from '~/utils/theme'
import { authState } from '~/utils/auth' import { authState } from '~/utils/auth'
import { fetchUnreadCount, notificationState } from '~/utils/notification' import { fetchUnreadCount, notificationState } from '~/utils/notification'
import { watch } from 'vue' import { ref, computed, watch, onMounted } from 'vue'
import { API_BASE_URL } from '~/main' import { API_BASE_URL } from '~/main'
export default { export default {
@@ -126,18 +126,39 @@ export default {
default: true default: true
} }
}, },
data() { async setup(props, { emit }) {
return { const router = useRouter()
categories: [], const categories = ref([])
tags: [], const tags = ref([])
categoryOpen: true, const categoryOpen = ref(true)
tagOpen: true, const tagOpen = ref(true)
isLoadingCategory: false, const isLoadingCategory = ref(false)
isLoadingTag: false const isLoadingTag = ref(false)
const categoryData = ref([])
const tagData = ref([])
const fetchCategoryData = async () => {
isLoadingCategory.value = true
const res = await fetch(`${API_BASE_URL}/api/categories`)
const data = await res.json()
categoryData.value = data
isLoadingCategory.value = false
} }
},
computed: { const fetchTagData = async () => {
iconClass() { isLoadingTag.value = true
const res = await fetch(`${API_BASE_URL}/api/tags?limit=10`)
const data = await res.json()
tagData.value = data
isLoadingTag.value = false
}
onMounted(() => {
// fetchCategoryData()
// fetchTagData()
})
const iconClass = computed(() => {
switch (themeState.mode) { switch (themeState.mode) {
case ThemeMode.DARK: case ThemeMode.DARK:
return 'fas fa-moon' return 'fas fa-moon'
@@ -146,18 +167,14 @@ export default {
default: default:
return 'fas fa-desktop' return 'fas fa-desktop'
} }
}, })
unreadCount() {
return notificationState.unreadCount const unreadCount = computed(() => notificationState.unreadCount)
}, const showUnreadCount = computed(() =>
showUnreadCount() { unreadCount.value > 99 ? '99+' : unreadCount.value
return this.unreadCount > 99 ? '99+' : this.unreadCount )
}, const shouldShowStats = computed(() => authState.role === 'ADMIN')
shouldShowStats() {
return authState.role === 'ADMIN'
}
},
async mounted() {
const updateCount = async () => { const updateCount = async () => {
if (authState.loggedIn) { if (authState.loggedIn) {
await fetchUnreadCount() await fetchUnreadCount()
@@ -165,94 +182,64 @@ export default {
notificationState.unreadCount = 0 notificationState.unreadCount = 0
} }
} }
watch(() => authState.loggedIn, async () => { onMounted(async () => {
await updateCount() await updateCount()
watch(() => authState.loggedIn, updateCount)
}) })
const CAT_CACHE_KEY = 'menu-categories' const handleHomeClick = () => {
const TAG_CACHE_KEY = 'menu-tags' router.push('/').then(() => {
const cachedCategories = localStorage.getItem(CAT_CACHE_KEY)
if (cachedCategories) {
try {
this.categories = JSON.parse(cachedCategories)
} catch { /* ignore */ }
}
const cachedTags = localStorage.getItem(TAG_CACHE_KEY)
if (cachedTags) {
try {
this.tags = JSON.parse(cachedTags)
} catch { /* ignore */ }
}
this.isLoadingCategory = !cachedCategories
this.isLoadingTag = !cachedTags
const fetchCategories = () => {
fetch(`${API_BASE_URL}/api/categories`).then(res => {
if (res.ok) {
res.json().then(data => {
this.categories = data.slice(0, 10)
localStorage.setItem(CAT_CACHE_KEY, JSON.stringify(this.categories))
})
}
this.isLoadingCategory = false
})
}
const fetchTags = () => {
fetch(`${API_BASE_URL}/api/tags?limit=10`).then(res => {
if (res.ok) {
res.json().then(data => {
this.tags = data
localStorage.setItem(TAG_CACHE_KEY, JSON.stringify(this.tags))
})
}
this.isLoadingTag = false
})
}
if (cachedCategories) {
setTimeout(fetchCategories, 1500)
} else {
fetchCategories()
}
if (cachedTags) {
setTimeout(fetchTags, 1500)
} else {
fetchTags()
}
await updateCount()
},
methods: {
cycleTheme,
handleHomeClick() {
this.$router.push('/').then(() => {
window.location.reload() window.location.reload()
}) })
}, }
handleItemClick() {
if (window.innerWidth <= 768) this.$emit('item-click') const handleItemClick = () => {
}, if (window.innerWidth <= 768) emit('item-click')
isImageIcon(icon) { }
const isImageIcon = (icon) => {
if (!icon) return false if (!icon) return false
return /^https?:\/\//.test(icon) || icon.startsWith('/') return /^https?:\/\//.test(icon) || icon.startsWith('/')
}, }
gotoCategory(c) {
const gotoCategory = (c) => {
const value = encodeURIComponent(c.id ?? c.name) const value = encodeURIComponent(c.id ?? c.name)
this.$router router
.push({ path: '/', query: { category: value } }) .push({ path: '/', query: { category: value } }).then(() => {
this.handleItemClick() window.location.reload()
}, })
gotoTag(t) { handleItemClick()
}
const gotoTag = (t) => {
const value = encodeURIComponent(t.id ?? t.name) const value = encodeURIComponent(t.id ?? t.name)
this.$router router
.push({ path: '/', query: { tags: value } }) .push({ path: '/', query: { tags: value } }).then(() => {
this.handleItemClick() window.location.reload()
})
handleItemClick()
}
await Promise.all([fetchCategoryData(), fetchTagData()])
return {
categoryData,
tagData,
categoryOpen,
tagOpen,
isLoadingCategory,
isLoadingTag,
iconClass,
unreadCount,
showUnreadCount,
shouldShowStats,
cycleTheme,
handleHomeClick,
handleItemClick,
isImageIcon,
gotoCategory,
gotoTag
} }
} }
} }

View File

@@ -1,5 +1,5 @@
export const API_BASE_URL = 'https://www.open-isle.com' // export const API_BASE_URL = 'https://www.open-isle.com'
// export const API_BASE_URL = 'http://127.0.0.1:8081' export const API_BASE_URL = 'http://127.0.0.1:8081'
export const GOOGLE_CLIENT_ID = '777830451304-nt8afkkap18gui4f9entcha99unal744.apps.googleusercontent.com' export const GOOGLE_CLIENT_ID = '777830451304-nt8afkkap18gui4f9entcha99unal744.apps.googleusercontent.com'
export const GITHUB_CLIENT_ID = 'Ov23liVkO1NPAX5JyWxJ' export const GITHUB_CLIENT_ID = 'Ov23liVkO1NPAX5JyWxJ'
export const DISCORD_CLIENT_ID = '1394985417044000779' export const DISCORD_CLIENT_ID = '1394985417044000779'

View File

@@ -107,7 +107,7 @@
</template> </template>
<script> <script>
import { ref, onMounted, watch } from 'vue' import { ref, watch } from 'vue'
import { useRoute } from 'vue-router' import { useRoute } from 'vue-router'
import { useScrollLoadMore } from '~/utils/loadMore' import { useScrollLoadMore } from '~/utils/loadMore'
import { stripMarkdown } from '~/utils/markdown' import { stripMarkdown } from '~/utils/markdown'
@@ -131,12 +131,8 @@ export default {
SearchDropdown, SearchDropdown,
ClientOnly: () => import('vue').then(m => m.defineAsyncComponent(() => import('vue').then(() => ({ template: '<slot />' })))) ClientOnly: () => import('vue').then(m => m.defineAsyncComponent(() => import('vue').then(() => ({ template: '<slot />' }))))
}, },
setup() { async setup() {
const route = useRoute() const route = useRoute()
/**
* -------- 1. PARAMS & REFS --------
*/
const selectedCategory = ref('') const selectedCategory = ref('')
if (route.query.category) { if (route.query.category) {
const c = decodeURIComponent(route.query.category) const c = decodeURIComponent(route.query.category)
@@ -169,19 +165,6 @@ export default {
const pageSize = 10 const pageSize = 10
const allLoaded = ref(false) const allLoaded = ref(false)
/**
* -------- 2. CLIENTSIDE ONLY: LDRS REGISTER --------
* 这里使用动态 import 避免 SSR 阶段触发 HTMLElement 未定义错误。
*/
onMounted(async () => {
// 首次加载
fetchContent()
await loadOptions()
})
/**
* -------- 3. FETCH OPTION HELPERS --------
*/
const loadOptions = async () => { const loadOptions = async () => {
if (selectedCategory.value && !isNaN(selectedCategory.value)) { if (selectedCategory.value && !isNaN(selectedCategory.value)) {
try { try {
@@ -245,9 +228,6 @@ export default {
return url return url
} }
/**
* -------- 4. FETCH CORE --------
*/
const fetchPosts = async (reset = false) => { const fetchPosts = async (reset = false) => {
if (reset) { if (reset) {
page.value = 0 page.value = 0
@@ -299,7 +279,12 @@ export default {
if (isLoadingPosts.value || allLoaded.value) return if (isLoadingPosts.value || allLoaded.value) return
try { try {
isLoadingPosts.value = true isLoadingPosts.value = true
const res = await fetch(buildRankUrl()) const token = getToken()
const res = await fetch(buildRankUrl(), {
headers: {
Authorization: token ? `Bearer ${token}` : ''
}
})
isLoadingPosts.value = false isLoadingPosts.value = false
if (!res.ok) return if (!res.ok) return
const data = await res.json() const data = await res.json()
@@ -336,7 +321,12 @@ export default {
if (isLoadingPosts.value || allLoaded.value) return if (isLoadingPosts.value || allLoaded.value) return
try { try {
isLoadingPosts.value = true isLoadingPosts.value = true
const res = await fetch(buildReplyUrl()) const token = getToken()
const res = await fetch(buildReplyUrl(), {
headers: {
Authorization: token ? `Bearer ${token}` : ''
}
})
isLoadingPosts.value = false isLoadingPosts.value = false
if (!res.ok) return if (!res.ok) return
const data = await res.json() const data = await res.json()
@@ -366,11 +356,11 @@ export default {
const fetchContent = async (reset = false) => { const fetchContent = async (reset = false) => {
if (selectedTopic.value === '排行榜') { if (selectedTopic.value === '排行榜') {
fetchRanking(reset) await fetchRanking(reset)
} else if (selectedTopic.value === '最新回复') { } else if (selectedTopic.value === '最新回复') {
fetchLatestReply(reset) await fetchLatestReply(reset)
} else { } else {
fetchPosts(reset) await fetchPosts(reset)
} }
} }
@@ -386,6 +376,8 @@ export default {
const sanitizeDescription = text => stripMarkdown(text) const sanitizeDescription = text => stripMarkdown(text)
await Promise.all([loadOptions(), fetchContent()])
return { return {
topics, topics,
selectedTopic, selectedTopic,

View File

@@ -126,7 +126,7 @@ import Dropdown from '../../../components/Dropdown.vue'
export default { export default {
name: 'PostPageView', name: 'PostPageView',
components: { CommentItem, CommentEditor, BaseTimeline, ArticleTags, ArticleCategory, ReactionsGroup, DropdownMenu, VueEasyLightbox, Dropdown }, components: { CommentItem, CommentEditor, BaseTimeline, ArticleTags, ArticleCategory, ReactionsGroup, DropdownMenu, VueEasyLightbox, Dropdown },
setup() { async setup() {
const route = useRoute() const route = useRoute()
const postId = route.params.id const postId = route.params.id
const router = useRouter() const router = useRouter()
@@ -150,27 +150,35 @@ export default {
const commentSort = ref('NEWEST') const commentSort = ref('NEWEST')
const isFetchingComments = ref(false) const isFetchingComments = ref(false)
// record default metadata from the main document // record default metadata from the main document (client only)
const defaultTitle = document.title const defaultTitle = process.client ? document.title : ''
const metaDescriptionEl = document.querySelector('meta[name="description"]') const metaDescriptionEl = process.client
const defaultDescription = metaDescriptionEl ? metaDescriptionEl.getAttribute('content') : '' ? document.querySelector('meta[name="description"]')
const headerHeight = parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--header-height')) || 0 : null
const defaultDescription = process.client && metaDescriptionEl
? metaDescriptionEl.getAttribute('content')
: ''
const headerHeight = process.client
? parseFloat(getComputedStyle(document.documentElement).getPropertyValue('--header-height')) || 0
: 0
watch(title, t => { if (process.client) {
document.title = `OpenIsle - ${t}` watch(title, t => {
}) document.title = `OpenIsle - ${t}`
})
watch(postContent, c => { watch(postContent, c => {
if (metaDescriptionEl) { if (metaDescriptionEl) {
metaDescriptionEl.setAttribute('content', stripMarkdownLength(c, 400)) metaDescriptionEl.setAttribute('content', stripMarkdownLength(c, 400))
} }
}) })
onBeforeUnmount(() => { onBeforeUnmount(() => {
document.title = defaultTitle document.title = defaultTitle
if (metaDescriptionEl) metaDescriptionEl.setAttribute('content', defaultDescription) if (metaDescriptionEl) metaDescriptionEl.setAttribute('content', defaultDescription)
window.removeEventListener('scroll', updateCurrentIndex) window.removeEventListener('scroll', updateCurrentIndex)
}) })
}
const lightboxVisible = ref(false) const lightboxVisible = ref(false)
const lightboxIndex = ref(0) const lightboxIndex = ref(0)
@@ -294,7 +302,7 @@ export default {
}) })
isWaitingFetchingPost.value = false; isWaitingFetchingPost.value = false;
if (!res.ok) { if (!res.ok) {
if (res.status === 404) { if (res.status === 404 && process.client) {
router.replace('/404') router.replace('/404')
} }
return return
@@ -581,10 +589,11 @@ export default {
router.push(`/users/${author.value.id}`) router.push(`/users/${author.value.id}`)
} }
await fetchPost()
onMounted(async () => { onMounted(async () => {
const hash = location.hash const hash = location.hash
const id = hash.startsWith('#comment-') ? hash.substring('#comment-'.length) : null const id = hash.startsWith('#comment-') ? hash.substring('#comment-'.length) : null
await fetchPost()
if (id) expandCommentPath(id) if (id) expandCommentPath(id)
updateCurrentIndex() updateCurrentIndex()
window.addEventListener('scroll', updateCurrentIndex) window.addEventListener('scroll', updateCurrentIndex)

View File

@@ -86,9 +86,15 @@ export function handleMarkdownClick(e) {
export function stripMarkdown(text) { export function stripMarkdown(text) {
const html = md.render(text || '') const html = md.render(text || '')
const el = document.createElement('div') // SSR 环境下没有 document
el.innerHTML = html if (typeof window === 'undefined') {
return el.textContent || el.innerText || '' // 用正则去除 HTML 标签
return html.replace(/<[^>]+>/g, '').replace(/\s+/g, ' ').trim()
} else {
const el = document.createElement('div')
el.innerHTML = html
return el.textContent || el.innerText || ''
}
} }
export function stripMarkdownLength(text, length) { export function stripMarkdownLength(text, length) {

View File

@@ -3,6 +3,20 @@ import { ref, computed } from 'vue'
const width = ref(0) const width = ref(0)
const isClient = ref(false) const isClient = ref(false)
// 检测移动设备的用户代理字符串
const isMobileUserAgent = () => {
if (typeof navigator === 'undefined') return false
const userAgent = navigator.userAgent.toLowerCase()
const mobileKeywords = [
'android', 'iphone', 'ipad', 'ipod', 'blackberry', 'windows phone',
'mobile', 'tablet', 'opera mini', 'iemobile'
]
return mobileKeywords.some(keyword => userAgent.includes(keyword))
}
// 客户端初始化
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
isClient.value = true isClient.value = true
width.value = window.innerWidth width.value = window.innerWidth
@@ -11,4 +25,13 @@ if (typeof window !== 'undefined') {
}) })
} }
export const isMobile = computed(() => isClient.value && width.value <= 768) // 服务端和客户端的移动设备检测
export const isMobile = computed(() => {
if (isClient.value) {
// 客户端:优先使用窗口宽度,如果窗口宽度不可用则使用用户代理
return width.value > 0 ? width.value <= 768 : isMobileUserAgent()
} else {
// 服务端:使用用户代理字符串
return isMobileUserAgent()
}
})