feat(pokemon): add related Pokemon section to detail view

Fetch related Pokemon based on shared environment and favorite things
Add UI with habitat filtering and highlighted shared attributes
This commit is contained in:
2026-05-02 08:21:46 +08:00
parent f5ab96c2b1
commit 21bbbc7137
6 changed files with 240 additions and 3 deletions

View File

@@ -18,9 +18,11 @@ const route = useRoute();
const { t } = useI18n();
const pokemon = ref<PokemonDetail | null>(null);
const itemCategoryTab = ref('');
const relatedHabitatTab = ref('');
const detailTab = ref('details');
const timeOfDays = ['早晨', '中午', '傍晚', '晚上'];
const weathers = ['晴天', '阴天', '雨天'];
const relatedPokemonLimit = 6;
type HabitatRow = {
id: number;
@@ -42,6 +44,10 @@ function sortByOrder(values: Set<string>, order: string[]) {
});
}
function habitatTabValue(id: number): string {
return `habitat-${id}`;
}
function timeLabel(value: string): string {
const labels: Record<string, string> = {
早晨: t('appearance.morning'),
@@ -128,6 +134,31 @@ const favoriteThingItems = computed(() => {
return items.filter((item) => String(item.category.id) === itemCategoryTab.value);
});
const relatedHabitatTabs = computed<TabOption[]>(() => {
if (!pokemon.value?.relatedPokemon.length) {
return [];
}
const habitats = new Map<string, string>();
habitats.set(habitatTabValue(pokemon.value.environment.id), pokemon.value.environment.name);
pokemon.value.relatedPokemon.forEach((item) => {
habitats.set(habitatTabValue(item.environment.id), item.environment.name);
});
const tabs = [...habitats.entries()].map(([value, label]) => ({ value, label }));
return [...tabs, { value: 'all', label: t('common.all') }];
});
const relatedPokemonRows = computed(() => {
const rows = pokemon.value?.relatedPokemon ?? [];
const selectedTab = relatedHabitatTab.value || (pokemon.value ? habitatTabValue(pokemon.value.environment.id) : '');
if (selectedTab === 'all') {
return rows.slice(0, relatedPokemonLimit);
}
return rows.filter((item) => habitatTabValue(item.environment.id) === selectedTab).slice(0, relatedPokemonLimit);
});
const typeSlotClass = computed(() => ({
'pokemon-type-slots--single': (pokemon.value?.types.length ?? 0) === 1
}));
@@ -148,7 +179,9 @@ function formatImperialHeight(inches: number): string {
}
async function loadPokemonDetail() {
pokemon.value = await api.pokemonDetail(String(route.params.id));
const nextPokemon = await api.pokemonDetail(String(route.params.id));
pokemon.value = nextPokemon;
relatedHabitatTab.value = habitatTabValue(nextPokemon.environment.id);
}
onMounted(async () => {
@@ -168,6 +201,7 @@ watch(
() => route.params.id,
() => {
pokemon.value = null;
relatedHabitatTab.value = '';
detailTab.value = 'details';
void loadPokemonDetail();
}
@@ -312,6 +346,58 @@ watch(
<EntityChips :items="pokemon.favorite_things" />
</DetailSection>
<DetailSection :title="t('pages.pokemon.relatedPokemon')">
<template v-if="pokemon.relatedPokemon.length">
<Tabs
v-if="relatedHabitatTabs.length"
id="pokemon-related-habitats"
v-model="relatedHabitatTab"
:tabs="relatedHabitatTabs"
:label="t('pages.pokemon.relatedHabitat')"
/>
<ul v-if="relatedPokemonRows.length" class="row-list related-pokemon-list">
<li v-for="related in relatedPokemonRows" :key="related.id">
<div class="related-pokemon-row">
<div class="related-pokemon-row__header">
<RouterLink class="related-pokemon-row__name" :to="`/pokemon/${related.id}`">#{{ related.id }} {{ related.name }}</RouterLink>
<span
class="chip related-pokemon-row__environment"
:class="{ 'related-pokemon-row__environment--match': related.environment.id === pokemon.environment.id }"
>
{{ related.environment.name }}
</span>
</div>
<div class="related-pokemon-row__content">
<div class="related-pokemon-row__group">
<span class="related-pokemon-row__label">{{ t('pages.pokemon.skills') }}</span>
<EntityChips v-if="related.skills.length" :items="related.skills" />
<span v-else class="meta-line">{{ t('common.none') }}</span>
</div>
<div class="related-pokemon-row__group">
<span class="related-pokemon-row__label">{{ t('pages.pokemon.favoriteThings') }}</span>
<div v-if="related.favorite_things.length" class="chips">
<span
v-for="thing in related.favorite_things"
:key="thing.id"
class="chip related-favourite-chip"
:class="{ 'related-favourite-chip--match': thing.matches }"
>
{{ thing.name }}
</span>
</div>
<span v-else class="meta-line">{{ t('common.none') }}</span>
</div>
</div>
</div>
</li>
</ul>
<p v-else class="meta-line">{{ t('common.none') }}</p>
</template>
<p v-else class="meta-line">{{ t('common.none') }}</p>
</DetailSection>
<DetailSection :title="t('pages.pokemon.relatedItems')">
<template v-if="pokemon.favoriteThingItems.length">
<Tabs