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

@@ -223,6 +223,7 @@ Pokemon 详情页展示:
- 特长掉落物品 - 特长掉落物品
- 喜欢的环境 - 喜欢的环境
- 喜欢的东西 - 喜欢的东西
- 相关 Pokemon按相同喜欢的环境优先其次按共同喜欢的东西数量从多到少排序支持按喜欢的环境筛选默认筛选当前 Pokemon 的喜欢的环境,也可切换到其他喜欢的环境或全部;每个筛选视图最多展示 6 个;展示名称、喜欢的环境、特长和喜欢的东西,并高亮共同喜欢的东西
- 关联喜欢的东西的物品 - 关联喜欢的东西的物品
- 出现的栖息地 - 出现的栖息地
- 最后编辑信息 - 最后编辑信息

View File

@@ -2000,8 +2000,12 @@ export async function getPokemon(id: number, locale = defaultLocale) {
const itemName = localizedName('items', 'i', locale); const itemName = localizedName('items', 'i', locale);
const categoryName = localizedName('item-categories', 'c', locale); const categoryName = localizedName('item-categories', 'c', locale);
const tagName = localizedName('favorite-things', 'ft', locale); const tagName = localizedName('favorite-things', 'ft', locale);
const relatedPokemonName = localizedName('pokemon', 'related_pokemon', locale);
const relatedEnvironmentName = localizedName('environments', 'related_environment', locale);
const relatedSkillName = localizedName('skills', 'related_skill', locale);
const relatedFavoriteThingName = localizedName('favorite-things', 'related_favorite_thing', locale);
const [habitats, itemDrops, favoriteThingItems, editHistory] = await Promise.all([ const [habitats, itemDrops, favoriteThingItems, relatedPokemon, editHistory] = await Promise.all([
query( query(
` `
SELECT SELECT
@@ -2049,6 +2053,75 @@ export async function getPokemon(id: number, locale = defaultLocale) {
`, `,
[id] [id]
), ),
query(
`
WITH current_pokemon AS (
SELECT p.id, p.environment_id
FROM pokemon p
WHERE p.id = $1
),
current_favourites AS (
SELECT pft.favorite_thing_id
FROM pokemon_favorite_things pft
WHERE pft.pokemon_id = $1
),
scored_pokemon AS (
SELECT
related_pokemon.id,
related_pokemon.sort_order,
(related_pokemon.environment_id = current_pokemon.environment_id) AS "environmentMatches",
COUNT(current_favourites.favorite_thing_id)::integer AS "favoriteThingMatchCount"
FROM current_pokemon
JOIN pokemon related_pokemon ON related_pokemon.id <> current_pokemon.id
LEFT JOIN pokemon_favorite_things related_pokemon_favourite
ON related_pokemon_favourite.pokemon_id = related_pokemon.id
LEFT JOIN current_favourites
ON current_favourites.favorite_thing_id = related_pokemon_favourite.favorite_thing_id
GROUP BY related_pokemon.id, related_pokemon.sort_order, related_pokemon.environment_id, current_pokemon.environment_id
HAVING related_pokemon.environment_id = current_pokemon.environment_id
OR COUNT(current_favourites.favorite_thing_id) > 0
)
SELECT
related_pokemon.id,
${relatedPokemonName} AS name,
json_build_object('id', related_environment.id, 'name', ${relatedEnvironmentName}) AS environment,
COALESCE((
SELECT json_agg(
json_build_object(
'id', related_skill.id,
'name', ${relatedSkillName},
'hasItemDrop', related_skill.has_item_drop
)
ORDER BY ${orderByEntity('related_skill')}
)
FROM pokemon_skills related_pokemon_skill
JOIN skills related_skill ON related_skill.id = related_pokemon_skill.skill_id
WHERE related_pokemon_skill.pokemon_id = related_pokemon.id
), '[]'::json) AS skills,
COALESCE((
SELECT json_agg(
json_build_object(
'id', related_favorite_thing.id,
'name', ${relatedFavoriteThingName},
'matches', EXISTS (
SELECT 1
FROM current_favourites
WHERE current_favourites.favorite_thing_id = related_favorite_thing.id
)
)
ORDER BY ${orderByEntity('related_favorite_thing')}
)
FROM pokemon_favorite_things related_pokemon_favourite
JOIN favorite_things related_favorite_thing ON related_favorite_thing.id = related_pokemon_favourite.favorite_thing_id
WHERE related_pokemon_favourite.pokemon_id = related_pokemon.id
), '[]'::json) AS favorite_things
FROM scored_pokemon
JOIN pokemon related_pokemon ON related_pokemon.id = scored_pokemon.id
JOIN environments related_environment ON related_environment.id = related_pokemon.environment_id
ORDER BY scored_pokemon."environmentMatches" DESC, scored_pokemon."favoriteThingMatchCount" DESC, scored_pokemon.sort_order, related_pokemon.id
`,
[id]
),
getEditHistory('pokemon', id) getEditHistory('pokemon', id)
]); ]);
@@ -2064,7 +2137,7 @@ export async function getPokemon(id: number, locale = defaultLocale) {
})) }))
: []; : [];
return { ...pokemon, skills, habitats, favoriteThingItems, editHistory }; return { ...pokemon, skills, habitats, favoriteThingItems, relatedPokemon, editHistory };
} }
function cleanPokemonPayload(payload: Record<string, unknown>): PokemonPayload { function cleanPokemonPayload(payload: Record<string, unknown>): PokemonPayload {

View File

@@ -142,6 +142,8 @@ const messages = {
skillDrop: '{name} drop', skillDrop: '{name} drop',
dropItem: 'Drop item', dropItem: 'Drop item',
searchPokemon: 'Search Pokemon', searchPokemon: 'Search Pokemon',
relatedPokemon: 'Related Pokemon',
relatedHabitat: 'Related Pokemon habitat',
relatedItems: 'Related items', relatedItems: 'Related items',
relatedItemCategory: 'Related item category', relatedItemCategory: 'Related item category',
habitats: 'Habitats', habitats: 'Habitats',
@@ -571,6 +573,8 @@ const messages = {
skillDrop: '{name}掉落物', skillDrop: '{name}掉落物',
dropItem: '掉落物', dropItem: '掉落物',
searchPokemon: '搜索 Pokemon', searchPokemon: '搜索 Pokemon',
relatedPokemon: '相关 Pokemon',
relatedHabitat: '相关 Pokemon 栖息地',
relatedItems: '关联物品', relatedItems: '关联物品',
relatedItemCategory: '关联物品分类', relatedItemCategory: '关联物品分类',
habitats: '栖息地', habitats: '栖息地',

View File

@@ -82,9 +82,18 @@ export interface Pokemon extends EditInfo {
favorite_things: NamedEntity[]; 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 { export interface PokemonDetail extends Pokemon {
skills: Array<Skill & { itemDrop: NamedEntity | null }>; skills: Array<Skill & { itemDrop: NamedEntity | null }>;
favoriteThingItems: Array<NamedEntity & { category: NamedEntity; tags: NamedEntity[] }>; favoriteThingItems: Array<NamedEntity & { category: NamedEntity; tags: NamedEntity[] }>;
relatedPokemon: RelatedPokemon[];
editHistory: EditHistoryEntry[]; editHistory: EditHistoryEntry[];
habitats: Array<{ habitats: Array<{
id: number; id: number;

View File

@@ -2630,6 +2630,70 @@ button:disabled,
justify-content: flex-end; 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 { .detail-text {
margin: 0; margin: 0;
color: var(--ink-soft); color: var(--ink-soft);

View File

@@ -18,9 +18,11 @@ const route = useRoute();
const { t } = useI18n(); const { t } = useI18n();
const pokemon = ref<PokemonDetail | null>(null); const pokemon = ref<PokemonDetail | null>(null);
const itemCategoryTab = ref(''); const itemCategoryTab = ref('');
const relatedHabitatTab = ref('');
const detailTab = ref('details'); const detailTab = ref('details');
const timeOfDays = ['早晨', '中午', '傍晚', '晚上']; const timeOfDays = ['早晨', '中午', '傍晚', '晚上'];
const weathers = ['晴天', '阴天', '雨天']; const weathers = ['晴天', '阴天', '雨天'];
const relatedPokemonLimit = 6;
type HabitatRow = { type HabitatRow = {
id: number; 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 { function timeLabel(value: string): string {
const labels: Record<string, string> = { const labels: Record<string, string> = {
早晨: t('appearance.morning'), 早晨: t('appearance.morning'),
@@ -128,6 +134,31 @@ const favoriteThingItems = computed(() => {
return items.filter((item) => String(item.category.id) === itemCategoryTab.value); 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(() => ({ const typeSlotClass = computed(() => ({
'pokemon-type-slots--single': (pokemon.value?.types.length ?? 0) === 1 'pokemon-type-slots--single': (pokemon.value?.types.length ?? 0) === 1
})); }));
@@ -148,7 +179,9 @@ function formatImperialHeight(inches: number): string {
} }
async function loadPokemonDetail() { 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 () => { onMounted(async () => {
@@ -168,6 +201,7 @@ watch(
() => route.params.id, () => route.params.id,
() => { () => {
pokemon.value = null; pokemon.value = null;
relatedHabitatTab.value = '';
detailTab.value = 'details'; detailTab.value = 'details';
void loadPokemonDetail(); void loadPokemonDetail();
} }
@@ -312,6 +346,58 @@ watch(
<EntityChips :items="pokemon.favorite_things" /> <EntityChips :items="pokemon.favorite_things" />
</DetailSection> </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')"> <DetailSection :title="t('pages.pokemon.relatedItems')">
<template v-if="pokemon.favoriteThingItems.length"> <template v-if="pokemon.favoriteThingItems.length">
<Tabs <Tabs