From 5ef1f4ecc94234efd19c3d7526f712ad54540f74 Mon Sep 17 00:00:00 2001 From: xiaomai Date: Wed, 6 May 2026 17:40:44 +0800 Subject: [PATCH] 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 --- frontend/plugins/03-detail-seo.server.ts | 76 ++++++++++++++++++++++++ frontend/src/views/HabitatDetail.vue | 7 +-- frontend/src/views/ItemDetail.vue | 7 +-- frontend/src/views/PokemonDetail.vue | 8 +-- frontend/src/views/RecipeDetail.vue | 7 +-- 5 files changed, 80 insertions(+), 25 deletions(-) create mode 100644 frontend/plugins/03-detail-seo.server.ts diff --git a/frontend/plugins/03-detail-seo.server.ts b/frontend/plugins/03-detail-seo.server.ts new file mode 100644 index 0000000..192cf0c --- /dev/null +++ b/frontend/plugins/03-detail-seo.server.ts @@ -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 } }).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 +): Promise { + 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; +} diff --git a/frontend/src/views/HabitatDetail.vue b/frontend/src/views/HabitatDetail.vue index 2928d28..451881f 100644 --- a/frontend/src/views/HabitatDetail.vue +++ b/frontend/src/views/HabitatDetail.vue @@ -34,7 +34,7 @@ const detailTabs = computed(() => [ { value: 'history', label: t('history.editHistory') } ]); -const { data: initialHabitat } = await useAsyncData( +const { data: initialHabitat } = useAsyncData( `habitat-detail:${activeHabitatRouteId() ?? 'none'}:${locale.value}`, async () => { const routeId = activeHabitatRouteId(); @@ -52,11 +52,6 @@ const { data: initialHabitat } = await useAsyncData( ); const initialHabitatLoaded = ref(false); -if (initialHabitat.value) { - habitat.value = initialHabitat.value; - initialHabitatLoaded.value = true; -} - const habitatSeo = computed(() => habitat.value && route.meta.editorModal !== true ? resolveSeo({ diff --git a/frontend/src/views/ItemDetail.vue b/frontend/src/views/ItemDetail.vue index 1a8e41e..de9dc90 100644 --- a/frontend/src/views/ItemDetail.vue +++ b/frontend/src/views/ItemDetail.vue @@ -73,7 +73,7 @@ const possibleTagEvidenceSections = computed(() => [ { key: 'neutral', title: t('pages.pokemon.tradingNeutral'), rows: item.value?.possibleTags?.evidence.neutral ?? [] } ]); -const { data: initialItem } = await useAsyncData( +const { data: initialItem } = useAsyncData( `item-detail:${String(route.name)}:${activeItemRouteId() ?? 'none'}:${locale.value}`, async () => { const routeId = activeItemRouteId(); @@ -92,11 +92,6 @@ const { data: initialItem } = await useAsyncData( ); const initialItemLoaded = ref(false); -if (initialItem.value) { - item.value = initialItem.value; - initialItemLoaded.value = true; -} - const itemSeo = computed(() => item.value && route.meta.editorModal !== true ? resolveSeo({ diff --git a/frontend/src/views/PokemonDetail.vue b/frontend/src/views/PokemonDetail.vue index 500e16a..4858c3e 100644 --- a/frontend/src/views/PokemonDetail.vue +++ b/frontend/src/views/PokemonDetail.vue @@ -41,7 +41,7 @@ const weathers = ['晴天', '阴天', '雨天']; const relatedPokemonLimit = 6; const pokemonDetailRouteNames = new Set(['pokemon-detail', 'pokemon-edit']); -const { data: initialPokemon } = await useAsyncData( +const { data: initialPokemon } = useAsyncData( `pokemon-detail:${activePokemonRouteId() ?? 'none'}:${locale.value}`, async () => { const routeId = activePokemonRouteId(); @@ -59,12 +59,6 @@ const { data: initialPokemon } = await useAsyncData( ); const initialPokemonLoaded = ref(false); -if (initialPokemon.value) { - pokemon.value = initialPokemon.value; - relatedHabitatTab.value = habitatTabValue(initialPokemon.value.environment.id); - initialPokemonLoaded.value = true; -} - const pokemonSeo = computed(() => pokemon.value && route.meta.editorModal !== true ? resolveSeo({ diff --git a/frontend/src/views/RecipeDetail.vue b/frontend/src/views/RecipeDetail.vue index 93d5504..ad1c771 100644 --- a/frontend/src/views/RecipeDetail.vue +++ b/frontend/src/views/RecipeDetail.vue @@ -42,7 +42,7 @@ const recipeSubtitle = computed(() => { return categoryName ?? t('pages.recipes.detailSubtitle'); }); -const { data: initialRecipe } = await useAsyncData( +const { data: initialRecipe } = useAsyncData( `recipe-detail:${String(route.params.id)}:${locale.value}`, async () => { try { @@ -55,11 +55,6 @@ const { data: initialRecipe } = await useAsyncData( ); const initialRecipeLoaded = ref(false); -if (initialRecipe.value) { - recipe.value = initialRecipe.value; - initialRecipeLoaded.value = true; -} - const recipeSeo = computed(() => recipe.value && route.meta.editorModal !== true ? resolveSeo({