feat(ssr): load initial data and SEO for public detail pages

Fetch initial content server-side for detail views and Life feed.
Bind detail-specific SEO head tags during SSR.
Extract resolvedSeoHead to share head tag generation.
This commit is contained in:
2026-05-06 12:01:00 +08:00
parent d66124862a
commit f92e97b747
10 changed files with 396 additions and 93 deletions

View File

@@ -47,6 +47,7 @@ import {
type LifeCategory,
type LifeComment,
type LifePost,
type LifePostsPage,
type LifeReactionType,
type ModerationUpdateDetail
} from '../services/api';
@@ -124,6 +125,47 @@ const allCategoryValue = 'all';
const allLanguageValue = 'all';
const allGameVersionValue = 'all';
type LifeInitialData = {
options: { lifeCategories: LifeCategory[]; gameVersions: GameVersion[] } | null;
languages: Language[] | null;
posts: LifePostsPage | null;
};
const { data: initialData } = await useAsyncData<LifeInitialData>(
`life-feed-initial:${locale.value}`,
async () => {
const [optionsResult, languagesResult, postsResult] = await Promise.allSettled([
api.options(),
api.languages(),
api.lifePosts({
limit: lifePostPageSize,
sort: 'latest'
})
]);
return {
options:
optionsResult.status === 'fulfilled'
? { lifeCategories: optionsResult.value.lifeCategories, gameVersions: optionsResult.value.gameVersions }
: null,
languages: languagesResult.status === 'fulfilled' ? languagesResult.value.filter((language) => language.enabled) : null,
posts: postsResult.status === 'fulfilled' ? postsResult.value : null
};
},
{ default: () => ({ options: null, languages: null, posts: null }) }
);
lifeCategories.value = initialData.value.options?.lifeCategories ?? [];
gameVersions.value = initialData.value.options?.gameVersions ?? [];
languages.value = initialData.value.languages ?? [];
posts.value = initialData.value.posts?.items ?? [];
nextCursor.value = initialData.value.posts?.nextCursor ?? null;
hasMorePosts.value = initialData.value.posts?.hasMore ?? false;
const initialOptionsLoaded = ref(initialData.value.options !== null);
const initialLanguagesLoaded = ref(initialData.value.languages !== null);
const initialPostsLoaded = ref(initialData.value.posts !== null);
loading.value = !initialPostsLoaded.value;
const reactionOptions = [
{ type: 'like', icon: iconReactionLike, labelKey: 'pages.life.reactionLike' },
{ type: 'helpful', icon: iconReactionHelpful, labelKey: 'pages.life.reactionHelpful' },
@@ -1334,10 +1376,22 @@ onMounted(() => {
document.addEventListener('click', closeReactionPickerFromDocument);
document.addEventListener('keydown', closeReactionPickerFromKeyboard);
window.addEventListener(moderationUpdateEvent, handleModerationUpdate);
void loadCurrentUser();
void loadLanguages();
void loadLifeCategories();
void loadPosts();
const hadAuthToken = getAuthToken() !== null;
void (async () => {
await loadCurrentUser();
if (!initialLanguagesLoaded.value) {
await loadLanguages();
initialLanguagesLoaded.value = true;
}
if (!initialOptionsLoaded.value) {
await loadLifeCategories();
initialOptionsLoaded.value = true;
}
if (!initialPostsLoaded.value || hadAuthToken) {
await loadPosts();
initialPostsLoaded.value = true;
}
})();
removeAuthListener = onAuthTokenChange(() => {
void (async () => {
await loadCurrentUser();