diff --git a/DESIGN.md b/DESIGN.md index 6e51c4f..befa2ba 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -223,6 +223,7 @@ Pokemon 详情页展示: - 特长掉落物品 - 喜欢的环境 - 喜欢的东西 +- 相关 Pokemon:按相同喜欢的环境优先,其次按共同喜欢的东西数量从多到少排序;支持按喜欢的环境筛选,默认筛选当前 Pokemon 的喜欢的环境,也可切换到其他喜欢的环境或全部;每个筛选视图最多展示 6 个;展示名称、喜欢的环境、特长和喜欢的东西,并高亮共同喜欢的东西 - 关联喜欢的东西的物品 - 出现的栖息地 - 最后编辑信息 diff --git a/backend/src/queries.ts b/backend/src/queries.ts index 0e0d7f8..76edd5b 100644 --- a/backend/src/queries.ts +++ b/backend/src/queries.ts @@ -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): PokemonPayload { diff --git a/frontend/src/i18n.ts b/frontend/src/i18n.ts index 5c0bf2f..fe15ac4 100644 --- a/frontend/src/i18n.ts +++ b/frontend/src/i18n.ts @@ -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: '栖息地', diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 04b6e9b..a7a4927 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -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; +} + export interface PokemonDetail extends Pokemon { skills: Array; favoriteThingItems: Array; + relatedPokemon: RelatedPokemon[]; editHistory: EditHistoryEntry[]; habitats: Array<{ id: number; diff --git a/frontend/src/styles/main.css b/frontend/src/styles/main.css index 661ae63..a59832f 100644 --- a/frontend/src/styles/main.css +++ b/frontend/src/styles/main.css @@ -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); diff --git a/frontend/src/views/PokemonDetail.vue b/frontend/src/views/PokemonDetail.vue index fc331da..1822530 100644 --- a/frontend/src/views/PokemonDetail.vue +++ b/frontend/src/views/PokemonDetail.vue @@ -18,9 +18,11 @@ const route = useRoute(); const { t } = useI18n(); const pokemon = ref(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, order: string[]) { }); } +function habitatTabValue(id: number): string { + return `habitat-${id}`; +} + function timeLabel(value: string): string { const labels: Record = { 早晨: t('appearance.morning'), @@ -128,6 +134,31 @@ const favoriteThingItems = computed(() => { return items.filter((item) => String(item.category.id) === itemCategoryTab.value); }); +const relatedHabitatTabs = computed(() => { + if (!pokemon.value?.relatedPokemon.length) { + return []; + } + + const habitats = new Map(); + 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( + + +

{{ t('common.none') }}

+
+