From 4d05618530f093b38fc86da89faef70af7df3616 Mon Sep 17 00:00:00 2001 From: xiaomai Date: Sun, 3 May 2026 09:51:45 +0800 Subject: [PATCH] feat: add images and profile grid layout to entity detail pages Return image data for related entities across all backend detail queries Display images or default placeholders in detail headers, chips, and lists Standardize Item, Recipe, and Habitat detail views with a new profile grid --- DESIGN.md | 35 ++-- backend/src/queries.ts | 146 ++++++++++--- frontend/src/components/EntityChips.vue | 21 +- frontend/src/services/api.ts | 27 ++- frontend/src/styles/main.css | 261 ++++++++++++++++++++++-- frontend/src/views/HabitatDetail.vue | 67 +++--- frontend/src/views/ItemDetail.vue | 202 +++++++++++------- frontend/src/views/PokemonDetail.vue | 112 +++++----- frontend/src/views/RecipeDetail.vue | 80 +++++++- system-wordings.ts | 2 + 10 files changed, 713 insertions(+), 240 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index e56f1df..f6d9e97 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -319,19 +319,19 @@ Pokemon 列表功能: Pokemon 详情页展示: - 基本信息 -- 已配置图片时,详情主内容在六维 Stats 右侧展示正方形居中的 Pokédex 风格图片;页面内不直接展示图片版本、状态或描述,用户可通过图片 Modal 查看详情;未配置图片时不显示图片区。 +- 详情主内容在六维 Stats 右侧始终保留正方形图片区;已配置图片时展示居中的 Pokédex 风格图片,未配置图片时展示默认 Poké Ball 占位符;页面内不直接展示图片版本、状态或描述,用户可通过图片 Modal 查看详情。 - 主内容顶部按以下布局展示: - 左上:Genus & Details;无区块标题;如有 Genus,先展示 Genus,再以分割线连接 Details 内容 - 左下:Height / Weight 与 Types 按 2:1 比例并排;Height / Weight 无区块标题,在 Dimension 区内左右并排展示并以中间分割线隔开,每组按英制、分割线、公制、标签上下排列;Types 不显示 Type 1 / Type 2 文案,上下布局并居中展示 - - 右侧:六维 Stats;已配置图片时图片展示在 Stats 右侧 + - 右侧:六维 Stats;图片或默认占位符展示在 Stats 右侧 - 六维使用 ProgressBar 展示,最大值按 150 计算。 - 特长 -- 特长掉落物品 +- 特长掉落物品:展示掉落物品图标;未配置图标时显示默认物品标记占位符 - 喜欢的环境 - 喜欢的东西 -- 相关 Pokemon:与关联喜欢的东西的物品在桌面端左右并排展示;按相同喜欢的环境优先,其次按共同喜欢的东西数量从多到少排序;支持按喜欢的环境筛选,默认筛选当前 Pokemon 的喜欢的环境,也可切换到其他喜欢的环境或全部;每个筛选视图最多展示 6 个;每项第一行左侧展示名称,右侧展示特长和喜欢的环境,第二行展示喜欢的东西,并高亮共同喜欢的东西 -- 关联喜欢的东西的物品:与相关 Pokemon 在桌面端左右并排展示 -- 出现的栖息地 +- 相关 Pokemon:与关联喜欢的东西的物品在桌面端左右并排展示;按相同喜欢的环境优先,其次按共同喜欢的东西数量从多到少排序;支持按喜欢的环境筛选,默认筛选当前 Pokemon 的喜欢的环境,也可切换到其他喜欢的环境或全部;每个筛选视图最多展示 6 个;每项左侧展示 Pokemon 图片或默认 Poké Ball 占位符,原列表项信息布局保持不变,第一行左侧展示名称,右侧展示特长和喜欢的环境,第二行展示喜欢的东西,并高亮共同喜欢的东西 +- 关联喜欢的东西的物品:与相关 Pokemon 在桌面端左右并排展示;每项展示物品图标,未配置图标时显示默认物品标记占位符 +- 出现的栖息地:每行左侧展示栖息地图片,未配置图片时显示默认栖息地标记占位符 - 最后编辑信息 - 讨论 - 编辑历史:通过详情页 Tabs 展示 @@ -368,16 +368,17 @@ Pokemon 详情页展示: 物品详情页展示: - 基本信息 -- 图标图片和图片上传历史 +- 当前图标图片;未配置图标时展示默认物品标记占位符 +- 顶部按图标 / 占位符与核心信息概览并排展示,移动端改为单列;顶部概览卡片不显示 `Image` / `Details` 通用区块标题,也不展示图片历史缩略图 - 分类 - 用途 - 入手方式 - 客制化 - 标签 -- 关联材料单 -- 作为材料出现的材料单 -- 相关栖息地 -- 相关 Pokemon 掉落 +- 关联材料单:展示结果物品图标和材料物品图标;未配置图标时显示默认物品标记占位符 +- 作为材料出现的材料单:展示结果物品图标和材料物品图标;未配置图标时显示默认物品标记占位符 +- 相关栖息地:展示栖息地图片或默认栖息地标记占位符,并展示配方材料物品图标 +- 相关 Pokemon 掉落:展示 Pokemon 图片;未配置图片时显示默认 Poké Ball 占位符 - 最后编辑信息 - 讨论 - 编辑历史 @@ -409,9 +410,10 @@ Pokemon 详情页展示: 材料单详情页展示: -- 结果物品 +- 结果物品图片或默认材料单标记占位符;顶部概览卡片不显示 `Image` / `Details` 通用区块标题 +- 结果物品名称、分类和用途;`GET /api/recipes/:id` 的 `item` 字段返回展示所需的 `id`、`name`、`image`、`category`、`usage` - 入手方式 -- 需要材料列表 +- 需要材料列表:展示材料物品图标;未配置图标时显示默认物品标记占位符 - 最后编辑信息 - 讨论 - 编辑历史 @@ -450,9 +452,10 @@ Pokemon 出现配置: 栖息地详情页展示: -- 图片和图片上传历史 -- 配方列表 -- 可能出现的 Pokemon 列表 +- 当前图片;未配置图片时展示默认栖息地标记占位符 +- 顶部按图片 / 占位符与核心信息概览并排展示,移动端改为单列;顶部概览卡片不显示 `Image` / `Details` 通用区块标题,也不展示图片历史缩略图 +- 配方列表:展示材料物品图标;未配置图标时显示默认物品标记占位符 +- 可能出现的 Pokemon 列表:展示 Pokemon 图片;未配置图片时显示默认 Poké Ball 占位符 - 出现时间 - 出现天气 - 稀有度 diff --git a/backend/src/queries.ts b/backend/src/queries.ts index 3c21863..e45983d 100644 --- a/backend/src/queries.ts +++ b/backend/src/queries.ts @@ -386,6 +386,32 @@ function uploadedImageJson(pathExpression: string): string { `; } +function pokemonImageJson(alias: string): string { + return ` + CASE + WHEN ${alias}.image_path LIKE '/sprites/%' THEN json_build_object( + 'path', ${alias}.image_path, + 'url', ${sqlLiteral(pokemonSpriteBaseUrl)} || ${alias}.image_path, + 'style', ${alias}.image_style, + 'version', ${alias}.image_version, + 'variant', ${alias}.image_variant, + 'description', ${alias}.image_description, + 'source', 'sprite' + ) + WHEN ${alias}.image_path <> '' THEN json_build_object( + 'path', ${alias}.image_path, + 'url', ${sqlLiteral(uploadPublicBaseUrl)} || ${alias}.image_path, + 'style', 'Upload', + 'version', 'Community upload', + 'variant', ${alias}.name, + 'description', '', + 'source', 'upload' + ) + ELSE NULL + END + `; +} + function imagePathLabel(path: string | null | undefined): string { const cleanPath = path?.trim() ?? ''; if (cleanPath === '') { @@ -1816,27 +1842,7 @@ function pokemonProjection(locale: string): string { round((p.height_inches * 0.0254)::numeric, 2)::double precision AS "heightMeters", p.weight_pounds AS "weightPounds", round((p.weight_pounds * 0.45359237)::numeric, 2)::double precision AS "weightKg", - CASE - WHEN p.image_path LIKE '/sprites/%' THEN json_build_object( - 'path', p.image_path, - 'url', '${pokemonSpriteBaseUrl}' || p.image_path, - 'style', p.image_style, - 'version', p.image_version, - 'variant', p.image_variant, - 'description', p.image_description, - 'source', 'sprite' - ) - WHEN p.image_path <> '' THEN json_build_object( - 'path', p.image_path, - 'url', ${sqlLiteral(uploadPublicBaseUrl)} || p.image_path, - 'style', 'Upload', - 'version', 'Community upload', - 'variant', p.name, - 'description', '', - 'source', 'upload' - ) - ELSE NULL - END AS image, + ${pokemonImageJson('p')} AS image, json_build_object( 'hp', p.hp, 'attack', p.attack, @@ -3037,6 +3043,7 @@ export async function getPokemon(id: number, locale = defaultLocale) { SELECT h.id, ${habitatName} AS name, + ${uploadedImageJson('h.image_path')} AS image, hp.time_of_day, hp.weather, hp.rarity, @@ -3049,9 +3056,9 @@ export async function getPokemon(id: number, locale = defaultLocale) { `, [id] ), - query<{ skillId: number; id: number; name: string }>( + query<{ skillId: number; id: number; name: string; image: EntityImageValue | null }>( ` - SELECT psid.skill_id AS "skillId", i.id, ${itemName} AS name + SELECT psid.skill_id AS "skillId", i.id, ${itemName} AS name, ${uploadedImageJson('i.image_path')} AS image FROM pokemon_skill_item_drops psid JOIN skills s ON s.id = psid.skill_id JOIN items i ON i.id = psid.item_id @@ -3066,6 +3073,7 @@ export async function getPokemon(id: number, locale = defaultLocale) { SELECT i.id, ${itemName} AS name, + ${uploadedImageJson('i.image_path')} AS image, json_build_object('id', c.id, 'name', ${categoryName}) AS category, json_agg(json_build_object('id', ft.id, 'name', ${tagName}) ORDER BY ${orderByEntity('ft')}) AS tags FROM pokemon_favorite_things pft @@ -3074,7 +3082,7 @@ export async function getPokemon(id: number, locale = defaultLocale) { JOIN items i ON i.id = ift.item_id JOIN item_categories c ON c.id = i.category_id WHERE pft.pokemon_id = $1 - GROUP BY i.id, i.name, i.sort_order, c.id, c.name, c.sort_order + GROUP BY i.id, i.name, i.image_path, i.sort_order, c.id, c.name, c.sort_order ORDER BY ${orderByEntity('c')}, ${orderByEntity('i')} `, [id] @@ -3110,6 +3118,7 @@ export async function getPokemon(id: number, locale = defaultLocale) { SELECT related_pokemon.id, ${relatedPokemonName} AS name, + ${pokemonImageJson('related_pokemon')} AS image, json_build_object('id', related_environment.id, 'name', ${relatedEnvironmentName}) AS environment, COALESCE(( SELECT json_agg( @@ -3153,9 +3162,9 @@ export async function getPokemon(id: number, locale = defaultLocale) { ]); const dropsBySkill = itemDrops.reduce((itemsBySkill, item) => { - itemsBySkill.set(item.skillId, { id: item.id, name: item.name }); + itemsBySkill.set(item.skillId, { id: item.id, name: item.name, image: item.image }); return itemsBySkill; - }, new Map()); + }, new Map()); const skills = Array.isArray(pokemon.skills) ? pokemon.skills.map((skill: { id: number; name: string }) => ({ @@ -3464,7 +3473,15 @@ export async function getHabitat(id: number, locale = defaultLocale) { ${auditSelect('h', 'habitat_created_user', 'habitat_updated_user')}, ${uploadedImageJson('h.image_path')} AS image, COALESCE(( - SELECT json_agg(json_build_object('id', i.id, 'name', ${itemName}, 'quantity', hri.quantity) ORDER BY ${orderByEntity('i')}) + SELECT json_agg( + json_build_object( + 'id', i.id, + 'name', ${itemName}, + 'image', ${uploadedImageJson('i.image_path')}, + 'quantity', hri.quantity + ) + ORDER BY ${orderByEntity('i')} + ) FROM habitat_recipe_items hri JOIN items i ON i.id = hri.item_id WHERE hri.habitat_id = h.id @@ -3486,6 +3503,7 @@ export async function getHabitat(id: number, locale = defaultLocale) { SELECT p.id, ${pokemonName} AS name, + ${pokemonImageJson('p')} AS image, hp.time_of_day, hp.weather, hp.rarity, @@ -3733,6 +3751,8 @@ export async function getItem(id: number, locale = defaultLocale) { const acquisitionMethodName = localizedName('acquisition-methods', 'am', locale); const resultItemName = localizedName('items', 'result_item', locale); + const resultItemCategoryName = localizedName('item-categories', 'result_category', locale); + const resultItemUsageName = localizedName('item-usages', 'result_usage', locale); const materialItemName = localizedName('items', 'mi', locale); const habitatName = localizedName('habitats', 'h', locale); const recipeItemName = localizedName('items', 'recipe_item', locale); @@ -3763,14 +3783,33 @@ export async function getItem(id: number, locale = defaultLocale) { WHERE ram.recipe_id = r.id ), '[]'::json) AS acquisition_methods, COALESCE(( - SELECT json_agg(json_build_object('id', mi.id, 'name', ${materialItemName}, 'quantity', rm.quantity) ORDER BY ${orderByEntity('mi')}) + SELECT json_agg( + json_build_object( + 'id', mi.id, + 'name', ${materialItemName}, + 'image', ${uploadedImageJson('mi.image_path')}, + 'quantity', rm.quantity + ) + ORDER BY ${orderByEntity('mi')} + ) FROM recipe_materials rm JOIN items mi ON mi.id = rm.item_id WHERE rm.recipe_id = r.id ), '[]'::json) AS materials, - json_build_object('id', result_item.id, 'name', ${resultItemName}) AS item + json_build_object( + 'id', result_item.id, + 'name', ${resultItemName}, + 'image', ${uploadedImageJson('result_item.image_path')}, + 'category', json_build_object('id', result_category.id, 'name', ${resultItemCategoryName}), + 'usage', CASE + WHEN result_usage.id IS NULL THEN NULL + ELSE json_build_object('id', result_usage.id, 'name', ${resultItemUsageName}) + END + ) AS item FROM recipes r JOIN items result_item ON result_item.id = r.item_id + JOIN item_categories result_category ON result_category.id = result_item.category_id + LEFT JOIN item_usages result_usage ON result_usage.id = result_item.usage_id ${auditJoins('r', 'recipe_created_user', 'recipe_updated_user')} WHERE r.item_id = $1 `, @@ -3781,8 +3820,17 @@ export async function getItem(id: number, locale = defaultLocale) { SELECT r.id, ${resultItemName} AS name, + ${uploadedImageJson('result_item.image_path')} AS image, COALESCE(( - SELECT json_agg(json_build_object('id', mi.id, 'name', ${materialItemName}, 'quantity', recipe_material.quantity) ORDER BY ${orderByEntity('mi')}) + SELECT json_agg( + json_build_object( + 'id', mi.id, + 'name', ${materialItemName}, + 'image', ${uploadedImageJson('mi.image_path')}, + 'quantity', recipe_material.quantity + ) + ORDER BY ${orderByEntity('mi')} + ) FROM recipe_materials recipe_material JOIN items mi ON mi.id = recipe_material.item_id WHERE recipe_material.recipe_id = r.id @@ -3800,8 +3848,17 @@ export async function getItem(id: number, locale = defaultLocale) { SELECT h.id, ${habitatName} AS name, + ${uploadedImageJson('h.image_path')} AS image, COALESCE(( - SELECT json_agg(json_build_object('id', recipe_item.id, 'name', ${recipeItemName}, 'quantity', recipe_item_row.quantity) ORDER BY ${orderByEntity('recipe_item')}) + SELECT json_agg( + json_build_object( + 'id', recipe_item.id, + 'name', ${recipeItemName}, + 'image', ${uploadedImageJson('recipe_item.image_path')}, + 'quantity', recipe_item_row.quantity + ) + ORDER BY ${orderByEntity('recipe_item')} + ) FROM habitat_recipe_items recipe_item_row JOIN items recipe_item ON recipe_item.id = recipe_item_row.item_id WHERE recipe_item_row.habitat_id = h.id @@ -3816,7 +3873,7 @@ export async function getItem(id: number, locale = defaultLocale) { query( ` SELECT - json_build_object('id', p.id, 'name', ${pokemonName}) AS pokemon, + json_build_object('id', p.id, 'name', ${pokemonName}, 'image', ${pokemonImageJson('p')}) AS pokemon, json_build_object('id', s.id, 'name', ${skillName}) AS skill FROM pokemon_skill_item_drops psid JOIN pokemon p ON p.id = psid.pokemon_id @@ -4025,6 +4082,8 @@ export async function listRecipes(paramsQuery: QueryParams = {}, locale = defaul export async function getRecipe(id: number, locale = defaultLocale) { const resultItemName = localizedName('items', 'result_item', locale); + const resultItemCategoryName = localizedName('item-categories', 'result_category', locale); + const resultItemUsageName = localizedName('item-usages', 'result_usage', locale); const acquisitionMethodName = localizedName('acquisition-methods', 'am', locale); const materialItemName = localizedName('items', 'i', locale); @@ -4041,14 +4100,33 @@ export async function getRecipe(id: number, locale = defaultLocale) { WHERE ram.recipe_id = r.id ), '[]'::json) AS acquisition_methods, COALESCE(( - SELECT json_agg(json_build_object('id', i.id, 'name', ${materialItemName}, 'quantity', rm.quantity) ORDER BY ${orderByEntity('i')}) + SELECT json_agg( + json_build_object( + 'id', i.id, + 'name', ${materialItemName}, + 'image', ${uploadedImageJson('i.image_path')}, + 'quantity', rm.quantity + ) + ORDER BY ${orderByEntity('i')} + ) FROM recipe_materials rm JOIN items i ON i.id = rm.item_id WHERE rm.recipe_id = r.id ), '[]'::json) AS materials, - json_build_object('id', result_item.id, 'name', ${resultItemName}) AS item + json_build_object( + 'id', result_item.id, + 'name', ${resultItemName}, + 'image', ${uploadedImageJson('result_item.image_path')}, + 'category', json_build_object('id', result_category.id, 'name', ${resultItemCategoryName}), + 'usage', CASE + WHEN result_usage.id IS NULL THEN NULL + ELSE json_build_object('id', result_usage.id, 'name', ${resultItemUsageName}) + END + ) AS item FROM recipes r JOIN items result_item ON result_item.id = r.item_id + JOIN item_categories result_category ON result_category.id = result_item.category_id + LEFT JOIN item_usages result_usage ON result_usage.id = result_item.usage_id ${auditJoins('r', 'recipe_created_user', 'recipe_updated_user')} WHERE r.id = $1 `, diff --git a/frontend/src/components/EntityChips.vue b/frontend/src/components/EntityChips.vue index 7edd03c..12856dd 100644 --- a/frontend/src/components/EntityChips.vue +++ b/frontend/src/components/EntityChips.vue @@ -1,14 +1,29 @@