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:
@@ -142,6 +142,8 @@ const messages = {
|
||||
skillDrop: '{name} drop',
|
||||
dropItem: 'Drop item',
|
||||
searchPokemon: 'Search Pokemon',
|
||||
relatedPokemon: 'Related Pokemon',
|
||||
relatedHabitat: 'Related Pokemon habitat',
|
||||
relatedItems: 'Related items',
|
||||
relatedItemCategory: 'Related item category',
|
||||
habitats: 'Habitats',
|
||||
@@ -571,6 +573,8 @@ const messages = {
|
||||
skillDrop: '{name}掉落物',
|
||||
dropItem: '掉落物',
|
||||
searchPokemon: '搜索 Pokemon',
|
||||
relatedPokemon: '相关 Pokemon',
|
||||
relatedHabitat: '相关 Pokemon 栖息地',
|
||||
relatedItems: '关联物品',
|
||||
relatedItemCategory: '关联物品分类',
|
||||
habitats: '栖息地',
|
||||
|
||||
@@ -82,9 +82,18 @@ export interface Pokemon extends EditInfo {
|
||||
favorite_things: NamedEntity[];
|
||||
}
|
||||
|
||||
export interface RelatedPokemon {
|
||||
id: number;
|
||||
name: string;
|
||||
environment: NamedEntity;
|
||||
skills: Skill[];
|
||||
favorite_things: Array<NamedEntity & { matches: boolean }>;
|
||||
}
|
||||
|
||||
export interface PokemonDetail extends Pokemon {
|
||||
skills: Array<Skill & { itemDrop: NamedEntity | null }>;
|
||||
favoriteThingItems: Array<NamedEntity & { category: NamedEntity; tags: NamedEntity[] }>;
|
||||
relatedPokemon: RelatedPokemon[];
|
||||
editHistory: EditHistoryEntry[];
|
||||
habitats: Array<{
|
||||
id: number;
|
||||
|
||||
@@ -2630,6 +2630,70 @@ button:disabled,
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.related-pokemon-list li {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.related-pokemon-row {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.related-pokemon-row__header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.related-pokemon-row__name {
|
||||
min-width: 0;
|
||||
color: var(--ink);
|
||||
font-weight: 900;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.related-pokemon-row__environment {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.related-pokemon-row__environment--match,
|
||||
.related-favourite-chip--match {
|
||||
border-color: rgba(255, 203, 5, 0.9);
|
||||
background: rgba(255, 203, 5, 0.34);
|
||||
color: #172036;
|
||||
}
|
||||
|
||||
.related-pokemon-row__content {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(240px, 100%), 1fr));
|
||||
gap: 12px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.related-pokemon-row__group {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.related-pokemon-row__label {
|
||||
color: var(--muted);
|
||||
font-size: 0.76rem;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.related-favourite-chip {
|
||||
gap: 6px;
|
||||
max-width: 100%;
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.detail-text {
|
||||
margin: 0;
|
||||
color: var(--ink-soft);
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user