refactor(frontend): move detail view state initialization to server plugin

Remove top-level await from useAsyncData in detail views
Remove manual state initialization blocks in components
Introduce 03-detail-seo.server.ts to handle SEO and state
This commit is contained in:
2026-05-06 17:40:44 +08:00
parent 4dc73d42cb
commit 5ef1f4ecc9
5 changed files with 80 additions and 25 deletions

View File

@@ -0,0 +1,76 @@
import { resolvedSeoHead, resolveSeo, type SeoConfig } from '../src/seo';
import { api } from '../src/services/api';
export default defineNuxtPlugin(async () => {
const route = useRoute();
const routeId = typeof route.params.id === 'string' && route.params.id.trim() !== '' ? route.params.id : null;
if (!routeId || typeof route.name !== 'string') {
return;
}
const nuxtApp = useNuxtApp();
const t = (nuxtApp.$pokopiaI18n as { global: { t: (key: string, values?: Record<string, string | number>) => string } }).global.t;
const seo = await detailSeo(String(route.name), routeId, t);
if (seo) {
useHead(resolvedSeoHead(resolveSeo(seo)));
}
});
async function detailSeo(
routeName: string,
routeId: string,
t: (key: string, values?: Record<string, string | number>) => string
): Promise<SeoConfig | null> {
try {
if (routeName === 'pokemon-detail') {
const pokemon = await api.pokemonDetail(routeId);
return {
title: `${pokemon.name} - ${t(pokemon.isEventItem ? 'pages.eventPokemon.title' : 'pages.pokemon.title')}`,
description: t('seo.pokemonDetailDescription', { name: pokemon.name }),
canonicalPath: `/pokemon/${pokemon.id}`,
image: pokemon.image?.url
};
}
if (routeName === 'habitat-detail') {
const habitat = await api.habitatDetail(routeId);
return {
title: `${habitat.name} - ${t(habitat.isEventItem ? 'pages.eventHabitats.title' : 'pages.habitats.title')}`,
description: t('seo.habitatDetailDescription', { name: habitat.name }),
canonicalPath: `/habitats/${habitat.id}`,
image: habitat.image?.url
};
}
if (routeName === 'item-detail' || routeName === 'ancient-artifact-detail') {
const item = await api.itemDetail(routeId);
const ancientArtifactRoute = routeName === 'ancient-artifact-detail';
if (ancientArtifactRoute && !item.ancientArtifactCategory) {
return null;
}
const titleKey = ancientArtifactRoute ? 'pages.ancientArtifacts.title' : item.isEventItem ? 'pages.eventItems.title' : 'pages.items.title';
const descriptionKey = ancientArtifactRoute ? 'seo.ancientArtifactDetailDescription' : 'seo.itemDetailDescription';
return {
title: `${item.name} - ${t(titleKey)}`,
description: t(descriptionKey, { name: item.name }),
canonicalPath: ancientArtifactRoute ? `/ancient-artifacts/${item.id}` : `/items/${item.id}`,
image: item.image?.url
};
}
if (routeName === 'recipe-detail') {
const recipe = await api.recipeDetail(routeId);
return {
title: `${recipe.name} - ${t('pages.recipes.title')}`,
description: t('seo.recipeDetailDescription', { name: recipe.name }),
canonicalPath: `/recipes/${recipe.id}`,
image: recipe.item.image?.url
};
}
} catch {
return null;
}
return null;
}

View File

@@ -34,7 +34,7 @@ const detailTabs = computed<TabOption[]>(() => [
{ value: 'history', label: t('history.editHistory') } { value: 'history', label: t('history.editHistory') }
]); ]);
const { data: initialHabitat } = await useAsyncData<HabitatDetail | null>( const { data: initialHabitat } = useAsyncData<HabitatDetail | null>(
`habitat-detail:${activeHabitatRouteId() ?? 'none'}:${locale.value}`, `habitat-detail:${activeHabitatRouteId() ?? 'none'}:${locale.value}`,
async () => { async () => {
const routeId = activeHabitatRouteId(); const routeId = activeHabitatRouteId();
@@ -52,11 +52,6 @@ const { data: initialHabitat } = await useAsyncData<HabitatDetail | null>(
); );
const initialHabitatLoaded = ref(false); const initialHabitatLoaded = ref(false);
if (initialHabitat.value) {
habitat.value = initialHabitat.value;
initialHabitatLoaded.value = true;
}
const habitatSeo = computed(() => const habitatSeo = computed(() =>
habitat.value && route.meta.editorModal !== true habitat.value && route.meta.editorModal !== true
? resolveSeo({ ? resolveSeo({

View File

@@ -73,7 +73,7 @@ const possibleTagEvidenceSections = computed(() => [
{ key: 'neutral', title: t('pages.pokemon.tradingNeutral'), rows: item.value?.possibleTags?.evidence.neutral ?? [] } { key: 'neutral', title: t('pages.pokemon.tradingNeutral'), rows: item.value?.possibleTags?.evidence.neutral ?? [] }
]); ]);
const { data: initialItem } = await useAsyncData<ItemDetail | null>( const { data: initialItem } = useAsyncData<ItemDetail | null>(
`item-detail:${String(route.name)}:${activeItemRouteId() ?? 'none'}:${locale.value}`, `item-detail:${String(route.name)}:${activeItemRouteId() ?? 'none'}:${locale.value}`,
async () => { async () => {
const routeId = activeItemRouteId(); const routeId = activeItemRouteId();
@@ -92,11 +92,6 @@ const { data: initialItem } = await useAsyncData<ItemDetail | null>(
); );
const initialItemLoaded = ref(false); const initialItemLoaded = ref(false);
if (initialItem.value) {
item.value = initialItem.value;
initialItemLoaded.value = true;
}
const itemSeo = computed(() => const itemSeo = computed(() =>
item.value && route.meta.editorModal !== true item.value && route.meta.editorModal !== true
? resolveSeo({ ? resolveSeo({

View File

@@ -41,7 +41,7 @@ const weathers = ['晴天', '阴天', '雨天'];
const relatedPokemonLimit = 6; const relatedPokemonLimit = 6;
const pokemonDetailRouteNames = new Set(['pokemon-detail', 'pokemon-edit']); const pokemonDetailRouteNames = new Set(['pokemon-detail', 'pokemon-edit']);
const { data: initialPokemon } = await useAsyncData<PokemonDetail | null>( const { data: initialPokemon } = useAsyncData<PokemonDetail | null>(
`pokemon-detail:${activePokemonRouteId() ?? 'none'}:${locale.value}`, `pokemon-detail:${activePokemonRouteId() ?? 'none'}:${locale.value}`,
async () => { async () => {
const routeId = activePokemonRouteId(); const routeId = activePokemonRouteId();
@@ -59,12 +59,6 @@ const { data: initialPokemon } = await useAsyncData<PokemonDetail | null>(
); );
const initialPokemonLoaded = ref(false); const initialPokemonLoaded = ref(false);
if (initialPokemon.value) {
pokemon.value = initialPokemon.value;
relatedHabitatTab.value = habitatTabValue(initialPokemon.value.environment.id);
initialPokemonLoaded.value = true;
}
const pokemonSeo = computed(() => const pokemonSeo = computed(() =>
pokemon.value && route.meta.editorModal !== true pokemon.value && route.meta.editorModal !== true
? resolveSeo({ ? resolveSeo({

View File

@@ -42,7 +42,7 @@ const recipeSubtitle = computed(() => {
return categoryName ?? t('pages.recipes.detailSubtitle'); return categoryName ?? t('pages.recipes.detailSubtitle');
}); });
const { data: initialRecipe } = await useAsyncData<RecipeDetail | null>( const { data: initialRecipe } = useAsyncData<RecipeDetail | null>(
`recipe-detail:${String(route.params.id)}:${locale.value}`, `recipe-detail:${String(route.params.id)}:${locale.value}`,
async () => { async () => {
try { try {
@@ -55,11 +55,6 @@ const { data: initialRecipe } = await useAsyncData<RecipeDetail | null>(
); );
const initialRecipeLoaded = ref(false); const initialRecipeLoaded = ref(false);
if (initialRecipe.value) {
recipe.value = initialRecipe.value;
initialRecipeLoaded.value = true;
}
const recipeSeo = computed(() => const recipeSeo = computed(() =>
recipe.value && route.meta.editorModal !== true recipe.value && route.meta.editorModal !== true
? resolveSeo({ ? resolveSeo({