Merge pull request #537 from nagisa77/feature/first_screen

fix: 首屏幕ssr优化
This commit is contained in:
Tim
2025-08-14 11:56:26 +08:00
committed by GitHub

View File

@@ -50,7 +50,7 @@
</div> </div>
</div> </div>
<div v-if="isLoadingPosts && articles.length === 0" class="loading-container"> <div v-if="pendingFirst" class="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>
@@ -60,7 +60,12 @@
</div> </div>
</div> </div>
<div class="article-item" v-for="article in articles" :key="article.id"> <div
v-if="!pendingFirst"
class="article-item"
v-for="article in articles"
:key="article.id"
>
<div class="article-main-container"> <div class="article-main-container">
<NuxtLink class="article-item-title main-item" :to="`/posts/${article.id}`"> <NuxtLink class="article-item-title main-item" :to="`/posts/${article.id}`">
<i v-if="article.pinned" class="fas fa-thumbtack pinned-icon"></i> <i v-if="article.pinned" class="fas fa-thumbtack pinned-icon"></i>
@@ -104,15 +109,14 @@
热门帖子功能开发中,敬请期待。 热门帖子功能开发中,敬请期待。
</div> </div>
<div v-else class="placeholder-container">分类浏览功能开发中,敬请期待。</div> <div v-else class="placeholder-container">分类浏览功能开发中,敬请期待。</div>
<div v-if="isLoadingPosts && articles.length > 0" class="loading-container bottom-loading"> <div v-if="isLoadingMore && articles.length > 0" class="loading-container bottom-loading">
<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> </div>
</div> </div>
</template> </template>
<script setup> <script setup>
import { ref, watch, onMounted, onBeforeUnmount } from 'vue' import { ref, watch, watchEffect, computed, onMounted, onBeforeUnmount } from 'vue'
import ArticleCategory from '~/components/ArticleCategory.vue' import ArticleCategory from '~/components/ArticleCategory.vue'
import ArticleTags from '~/components/ArticleTags.vue' import ArticleTags from '~/components/ArticleTags.vue'
import CategorySelect from '~/components/CategorySelect.vue' import CategorySelect from '~/components/CategorySelect.vue'
@@ -142,7 +146,9 @@ const selectedTags = ref([])
const route = useRoute() const route = useRoute()
const tagOptions = ref([]) const tagOptions = ref([])
const categoryOptions = ref([]) const categoryOptions = ref([])
const isLoadingPosts = ref(false)
const isLoadingMore = ref(false)
const topics = ref(['最新回复', '最新', '排行榜' /*, '热门', '类别'*/]) const topics = ref(['最新回复', '最新', '排行榜' /*, '热门', '类别'*/])
const selectedTopic = ref( const selectedTopic = ref(
route.query.view === 'ranking' ? '排行榜' : route.query.view === 'latest' ? '最新' : '最新回复', route.query.view === 'ranking' ? '排行榜' : route.query.view === 'latest' ? '最新' : '最新回复',
@@ -153,11 +159,11 @@ const pageSize = 10
const isMobile = useIsMobile() const isMobile = useIsMobile()
const allLoaded = ref(false) const allLoaded = ref(false)
/** URL 参数 -> 本地筛选值 **/
const selectedCategorySet = (category) => { const selectedCategorySet = (category) => {
const c = decodeURIComponent(category) const c = decodeURIComponent(category)
selectedCategory.value = isNaN(c) ? c : Number(c) selectedCategory.value = isNaN(c) ? c : Number(c)
} }
const selectedTagsSet = (tags) => { const selectedTagsSet = (tags) => {
const t = Array.isArray(tags) ? tags.join(',') : tags const t = Array.isArray(tags) ? tags.join(',') : tags
selectedTags.value = t selectedTags.value = t
@@ -167,23 +173,17 @@ const selectedTagsSet = (tags) => {
.map((v) => (isNaN(v) ? v : Number(v))) .map((v) => (isNaN(v) ? v : Number(v)))
} }
/** 初始化:仅在客户端首渲染时根据路由同步一次 **/
onMounted(() => { onMounted(() => {
const query = route.query const { category, tags } = route.query
const category = query.category if (category) selectedCategorySet(category)
const tags = query.tags if (tags) selectedTagsSet(tags)
if (category) {
selectedCategorySet(category)
}
if (tags) {
selectedTagsSet(tags)
}
}) })
/** 路由变更时同步筛选 **/
watch( watch(
() => route.query, () => route.query,
() => { (query) => {
const query = route.query
const category = query.category const category = query.category
const tags = query.tags const tags = query.tags
category && selectedCategorySet(category) category && selectedCategorySet(category)
@@ -191,18 +191,14 @@ watch(
}, },
) )
/** 选项加载(分类/标签名称回填) **/
const loadOptions = async () => { const loadOptions = async () => {
if (selectedCategory.value && !isNaN(selectedCategory.value)) { if (selectedCategory.value && !isNaN(selectedCategory.value)) {
try { try {
const res = await fetch(`${API_BASE_URL}/api/categories/${selectedCategory.value}`) const res = await fetch(`${API_BASE_URL}/api/categories/${selectedCategory.value}`)
if (res.ok) { if (res.ok) categoryOptions.value = [await res.json()]
categoryOptions.value = [await res.json()] } catch {}
}
} catch (e) {
/* ignore */
}
} }
if (selectedTags.value.length) { if (selectedTags.value.length) {
const arr = [] const arr = []
for (const t of selectedTags.value) { for (const t of selectedTags.value) {
@@ -210,74 +206,97 @@ const loadOptions = async () => {
try { try {
const r = await fetch(`${API_BASE_URL}/api/tags/${t}`) const r = await fetch(`${API_BASE_URL}/api/tags/${t}`)
if (r.ok) arr.push(await r.json()) if (r.ok) arr.push(await r.json())
} catch (e) { } catch {}
/* ignore */
}
} }
} }
tagOptions.value = arr tagOptions.value = arr
} }
} }
const buildUrl = () => { /** 列表 API 路径与查询参数 **/
let url = `${API_BASE_URL}/api/posts?page=${page.value}&pageSize=${pageSize}` const baseQuery = computed(() => ({
if (selectedCategory.value) { categoryId: selectedCategory.value || undefined,
url += `&categoryId=${selectedCategory.value}` tagIds: selectedTags.value.length ? selectedTags.value : undefined,
} }))
if (selectedTags.value.length) { const listApiPath = computed(() => {
selectedTags.value.forEach((t) => { if (selectedTopic.value === '排行榜') return '/api/posts/ranking'
url += `&tagIds=${t}` if (selectedTopic.value === '最新回复') return '/api/posts/latest-reply'
}) return '/api/posts'
} })
return url const buildUrl = ({ pageNo }) => {
const url = new URL(`${API_BASE_URL}${listApiPath.value}`)
url.searchParams.set('page', pageNo)
url.searchParams.set('pageSize', pageSize)
if (baseQuery.value.categoryId) url.searchParams.set('categoryId', baseQuery.value.categoryId)
if (baseQuery.value.tagIds)
for (const t of baseQuery.value.tagIds) url.searchParams.append('tagIds', t)
return url.toString()
} }
const tokenHeader = computed(() => {
const token = getToken()
return token ? { Authorization: `Bearer ${token}` } : {}
})
const buildRankUrl = () => { /** —— 首屏数据托管SSR —— **/
let url = `${API_BASE_URL}/api/posts/ranking?page=${page.value}&pageSize=${pageSize}` const asyncKey = computed(() => [
if (selectedCategory.value) { 'home:firstpage',
url += `&categoryId=${selectedCategory.value}` selectedTopic.value,
} String(baseQuery.value.categoryId ?? ''),
if (selectedTags.value.length) { JSON.stringify(baseQuery.value.tagIds ?? []),
selectedTags.value.forEach((t) => { ])
url += `&tagIds=${t}` const {
}) data: firstPage,
} pending: pendingFirst,
return url refresh: refreshFirst,
} } = await useAsyncData(
() => asyncKey.value.join('::'),
async () => {
const res = await $fetch(buildUrl({ pageNo: 0 }), { headers: tokenHeader.value })
const data = Array.isArray(res) ? res : []
return data.map((p) => ({
id: p.id,
title: p.title,
description: p.content,
category: p.category,
tags: p.tags || [],
members: (p.participants || []).map((m) => ({ id: m.id, avatar: m.avatar })),
comments: p.commentCount,
views: p.views,
time: TimeManager.format(
selectedTopic.value === '最新回复' ? p.lastReplyAt || p.createdAt : p.createdAt,
),
pinned: !!p.pinnedAt,
type: p.type,
}))
},
{
server: true,
default: () => [],
watch: [selectedTopic, baseQuery],
},
)
const buildReplyUrl = () => { /** 首屏/筛选变更:重置分页并灌入 firstPage **/
let url = `${API_BASE_URL}/api/posts/latest-reply?page=${page.value}&pageSize=${pageSize}` watch(
if (selectedCategory.value) { firstPage,
url += `&categoryId=${selectedCategory.value}` (data) => {
}
if (selectedTags.value.length) {
selectedTags.value.forEach((t) => {
url += `&tagIds=${t}`
})
}
return url
}
const fetchPosts = async (reset = false) => {
if (reset) {
page.value = 0 page.value = 0
allLoaded.value = false articles.value = [...(data || [])]
articles.value = [] allLoaded.value = (data?.length || 0) < pageSize
} },
if (isLoadingPosts.value || allLoaded.value) return { immediate: true },
try { )
isLoadingPosts.value = true
const token = getToken() /** —— 滚动加载更多 —— **/
const res = await fetch(buildUrl(), { let inflight = null
headers: { const fetchNextPage = async () => {
Authorization: token ? `Bearer ${token}` : '', if (allLoaded.value || pendingFirst.value || inflight) return
}, const nextPage = page.value + 1
}) isLoadingMore.value = true
isLoadingPosts.value = false inflight = $fetch(buildUrl({ pageNo: nextPage }), { headers: tokenHeader.value })
if (!res.ok) return .then((res) => {
const data = await res.json() const data = Array.isArray(res) ? res : []
articles.value.push( const mapped = data.map((p) => ({
...data.map((p) => ({
id: p.id, id: p.id,
title: p.title, title: p.title,
description: p.content, description: p.content,
@@ -286,142 +305,72 @@ const fetchPosts = async (reset = false) => {
members: (p.participants || []).map((m) => ({ id: m.id, avatar: m.avatar })), members: (p.participants || []).map((m) => ({ id: m.id, avatar: m.avatar })),
comments: p.commentCount, comments: p.commentCount,
views: p.views, views: p.views,
time: TimeManager.format(p.createdAt), time: TimeManager.format(
selectedTopic.value === '最新回复' ? p.lastReplyAt || p.createdAt : p.createdAt,
),
pinned: !!p.pinnedAt, pinned: !!p.pinnedAt,
type: p.type, type: p.type,
})), }))
) articles.value.push(...mapped)
if (data.length < pageSize) { if (data.length < pageSize) {
allLoaded.value = true allLoaded.value = true
} else { } else {
page.value += 1 page.value = nextPage
} }
} catch (e) {
console.error(e)
}
}
const fetchRanking = async (reset = false) => {
if (reset) {
page.value = 0
allLoaded.value = false
articles.value = []
}
if (isLoadingPosts.value || allLoaded.value) return
try {
isLoadingPosts.value = true
const token = getToken()
const res = await fetch(buildRankUrl(), {
headers: {
Authorization: token ? `Bearer ${token}` : '',
},
}) })
isLoadingPosts.value = false .finally(() => {
if (!res.ok) return inflight = null
const data = await res.json() isLoadingMore.value = false
articles.value.push(
...data.map((p) => ({
id: p.id,
title: p.title,
description: p.content,
category: p.category,
tags: p.tags || [],
members: (p.participants || []).map((m) => ({ id: m.id, avatar: m.avatar })),
comments: p.commentCount,
views: p.views,
time: TimeManager.format(p.createdAt),
pinned: !!p.pinnedAt,
type: p.type,
})),
)
if (data.length < pageSize) {
allLoaded.value = true
} else {
page.value += 1
}
} catch (e) {
console.error(e)
}
}
const fetchLatestReply = async (reset = false) => {
if (reset) {
page.value = 0
allLoaded.value = false
articles.value = []
}
if (isLoadingPosts.value || allLoaded.value) return
try {
isLoadingPosts.value = true
const token = getToken()
const res = await fetch(buildReplyUrl(), {
headers: {
Authorization: token ? `Bearer ${token}` : '',
},
}) })
isLoadingPosts.value = false
if (!res.ok) return
const data = await res.json()
articles.value.push(
...data.map((p) => ({
id: p.id,
title: p.title,
description: p.content,
category: p.category,
tags: p.tags || [],
members: (p.participants || []).map((m) => ({ id: m.id, avatar: m.avatar })),
comments: p.commentCount,
views: p.views,
time: TimeManager.format(p.lastReplyAt || p.createdAt),
pinned: !!p.pinnedAt,
type: p.type,
})),
)
if (data.length < pageSize) {
allLoaded.value = true
} else {
page.value += 1
}
} catch (e) {
console.error(e)
}
} }
const fetchContent = async (reset = false) => { /** 绑定滚动加载(避免挂载瞬间触发) **/
if (selectedTopic.value === '排行榜') { let initialReady = false
await fetchRanking(reset) const loadMoreGuarded = async () => {
} else if (selectedTopic.value === '最新回复') { if (!initialReady) return
await fetchLatestReply(reset) await fetchNextPage()
} else {
await fetchPosts(reset)
}
} }
useScrollLoadMore(loadMoreGuarded)
watch(
articles,
() => {
if (!initialReady && articles.value.length) initialReady = true
},
{ immediate: true },
)
const refreshHome = () => { /** 外部刷新事件(发帖后刷新首屏) **/
fetchContent(true) const refreshHome = async () => {
selectedCategory.value = ''
selectedTags.value = []
await refreshFirst()
} }
onMounted(() => { onMounted(() => {
window.addEventListener('refresh-home', refreshHome) window.addEventListener('refresh-home', refreshHome)
}) })
onBeforeUnmount(() => { onBeforeUnmount(() => {
window.removeEventListener('refresh-home', refreshHome) window.removeEventListener('refresh-home', refreshHome)
}) })
useScrollLoadMore(fetchContent) /** 切换分类/标签/TabuseAsyncData 已 watch这里只需确保 options 加载 **/
watch([selectedCategory, selectedTags], () => { watch([selectedCategory, selectedTags], () => {
fetchContent(true) loadOptions()
}) })
watch(selectedTopic, () => { watch(selectedTopic, () => {
fetchContent(true) // 仅当需要额外选项时加载
loadOptions()
}) })
const sanitizeDescription = (text) => stripMarkdown(text) /** 选项首屏加载:服务端执行一次;客户端兜底 **/
if (import.meta.server) {
await loadOptions()
}
onMounted(() => {
if (categoryOptions.value.length === 0 && tagOptions.value.length === 0) loadOptions()
})
await Promise.all([loadOptions(), fetchContent()]) /** 其他工具函数 **/
const sanitizeDescription = (text) => stripMarkdown(text)
</script> </script>
<style scoped> <style scoped>