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:
@@ -223,6 +223,7 @@ Pokemon 详情页展示:
|
||||
- 特长掉落物品
|
||||
- 喜欢的环境
|
||||
- 喜欢的东西
|
||||
- 相关 Pokemon:按相同喜欢的环境优先,其次按共同喜欢的东西数量从多到少排序;支持按喜欢的环境筛选,默认筛选当前 Pokemon 的喜欢的环境,也可切换到其他喜欢的环境或全部;每个筛选视图最多展示 6 个;展示名称、喜欢的环境、特长和喜欢的东西,并高亮共同喜欢的东西
|
||||
- 关联喜欢的东西的物品
|
||||
- 出现的栖息地
|
||||
- 最后编辑信息
|
||||
|
||||
@@ -2000,8 +2000,12 @@ export async function getPokemon(id: number, locale = defaultLocale) {
|
||||
const itemName = localizedName('items', 'i', locale);
|
||||
const categoryName = localizedName('item-categories', 'c', 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(
|
||||
`
|
||||
SELECT
|
||||
@@ -2049,6 +2053,75 @@ export async function getPokemon(id: number, locale = defaultLocale) {
|
||||
`,
|
||||
[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)
|
||||
]);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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