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 @@