From 575597b1468bde086b81075ca9fba908310995c5 Mon Sep 17 00:00:00 2001 From: xiaomai Date: Thu, 7 May 2026 16:21:11 +0800 Subject: [PATCH] feat(pokemon): enforce bidirectional opposite sync and hide opposite text Add unique indexes and transactional sync for opposite configurations Remove explicit opposite names and labels from Pokemon detail view --- DESIGN.md | 10 +-- backend/db/schema.sql | 8 +++ backend/src/queries.ts | 100 ++++++++++++++++++--------- frontend/src/styles/main.css | 10 --- frontend/src/views/PokemonDetail.vue | 10 +-- system-wordings.ts | 2 - 6 files changed, 83 insertions(+), 57 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index 9dfd5ac..bcf1673 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -466,12 +466,12 @@ - 名称 - Description:可为空,用于解释该 Ideal Habitat 对 Pokemon 栖息地选择的意义 -- Opposite:可为空,关联另一个喜欢的环境作为反义关系 +- Opposite:可为空,双向关联另一个喜欢的环境作为反义关系;每个喜欢的环境最多只能属于一组 Opposite 配对;设置、替换或清空一侧时,系统必须在同一事务中同步维护另一侧 ### 喜欢的东西 / 标签 - 名称 -- Opposite:可为空,关联另一个喜欢的东西 / 标签作为反义关系 +- Opposite:可为空,双向关联另一个喜欢的东西 / 标签作为反义关系;每个喜欢的东西 / 标签最多只能属于一组 Opposite 配对;设置、替换或清空一侧时,系统必须在同一事务中同步维护另一侧 - 同时用于: - Pokemon 喜欢的东西 - 物品标签 @@ -595,13 +595,13 @@ Pokemon 详情页展示: - 详情主内容顶部改为左侧 Pokemon 图片、右侧 Pokemon Description;已配置图片时展示居中的 Pokédex 风格图片,未配置图片时展示默认 Poké Ball 占位符;页面内不直接展示图片版本、状态或描述,用户可通过图片 Modal 查看详情。 - 详情页需要突出 Pokopia 机制核心要素: - Skills:影响栖息地选择、物品掉落和 Trading 行为 - - Ideal Habitat:影响栖息地选择和相关 Pokemon 对比;正文中展示名称、Description 和可选 Opposite - - Favourite Things:影响物品掉落、隐藏标签判断和 Trading 价格证据;正文中展示名称和可选 Opposite + - Ideal Habitat:影响栖息地选择和相关 Pokemon 对比;正文中只展示正向 Ideal Habitat 名称和 Description,不展示反义词 + - Favourite Things:影响物品掉落、隐藏标签判断和 Trading 价格证据;正文中只展示正向 Favourite Things,不展示反义词 - 特长掉落物品:当该 Pokemon 拥有支持掉落物的已选特长时默认展示;已配置时展示掉落物品图标,未配置时展示空状态 - Trading:当该 Pokemon 拥有支持 Trading 的已选特长时默认展示;分 Likes 与 Neutral 两组展示物品,Likes 表示交易价格 1.5x,Neutral 表示无加成,未配置观察时展示空状态;详情页双列列表高度受限,内容较多时每列内部独立滚动,避免 Section 被大量物品无限拉长 - Trading 可在详情页通过 Manage Trading Modal 维护;Modal 左侧顶部为同一行搜索框和分类下拉筛选,下面提供 Likes / Neutral 默认加入目标切换;搜索结果优先展示名称精确匹配、名称开头匹配和单词匹配的物品,再展示名称包含、分类或用途包含的物品;搜索框支持上下键切换当前结果、左右键切换默认加入组、Enter 将当前结果加入默认目标组;从左侧选择物品会加入当前默认目标组,右侧按 Likes / Neutral 分组展示已选物品,并可切换分组或移除;列表区域高度稳定,过滤结果减少时 Modal 不应抖动 - 参考资料 Tab:Height / Weight、Types 和六维 Stats 移到独立 Tab;该 Tab 必须注明这些数据只是参考 Pokédex 的展示设计,不属于 Pokopia 机制;六维使用 ProgressBar 展示,最大值按 150 计算 -- 相关 Pokemon:与关联喜欢的东西的物品在桌面端左右并排展示;按相同喜欢的环境优先,其次按共同喜欢的东西数量从多到少排序;支持按喜欢的环境筛选,默认筛选当前 Pokemon 的喜欢的环境,也可切换到其他喜欢的环境或全部;每个筛选视图最多展示 6 个;每项左侧展示 Pokemon 图片或默认 Poké Ball 占位符,原列表项信息布局保持不变,第一行左侧展示名称,右侧展示特长和喜欢的环境,第二行展示喜欢的东西,并高亮共同喜欢的东西;当相关 Pokemon 的 Ideal Habitat 或 Favourite Things 与当前 Pokemon 配置的 Opposite 反义关系命中时,使用红色标记并展示 Opposite 文案,不能只依赖颜色表达 +- 相关 Pokemon:与关联喜欢的东西的物品在桌面端左右并排展示;按相同喜欢的环境优先,其次按共同喜欢的东西数量从多到少排序;支持按喜欢的环境筛选,默认筛选当前 Pokemon 的喜欢的环境,也可切换到其他喜欢的环境或全部;每个筛选视图最多展示 6 个;每项左侧展示 Pokemon 图片或默认 Poké Ball 占位符,原列表项信息布局保持不变,第一行左侧展示名称,右侧展示特长和喜欢的环境,第二行展示喜欢的东西,并高亮共同喜欢的东西;当相关 Pokemon 的 Ideal Habitat 或 Favourite Things 与当前 Pokemon 配置的 Opposite 反义关系命中时,只使用红色标记,不展示反义词或额外 Opposite 文案 - 关联喜欢的东西的物品:与相关 Pokemon 在桌面端左右并排展示;每项展示物品图标,未配置图标时显示默认物品标记占位符 - 出现的栖息地:每行左侧展示栖息地图片,未配置图片时显示默认栖息地标记占位符 - 最后编辑信息 diff --git a/backend/db/schema.sql b/backend/db/schema.sql index 67fb1aa..e18f625 100644 --- a/backend/db/schema.sql +++ b/backend/db/schema.sql @@ -1575,6 +1575,14 @@ BEGIN END IF; END $$; +CREATE UNIQUE INDEX IF NOT EXISTS environments_opposite_environment_unique_idx + ON environments(opposite_environment_id) + WHERE opposite_environment_id IS NOT NULL; + +CREATE UNIQUE INDEX IF NOT EXISTS favorite_things_opposite_favorite_thing_unique_idx + ON favorite_things(opposite_favorite_thing_id) + WHERE opposite_favorite_thing_id IS NOT NULL; + UPDATE items SET dyeability = CASE WHEN dual_dyeable THEN 2 diff --git a/backend/src/queries.ts b/backend/src/queries.ts index fd2be93..9c8dc2d 100644 --- a/backend/src/queries.ts +++ b/backend/src/queries.ts @@ -2718,10 +2718,8 @@ function pokemonProjection(locale: string): string { const pokemonDetails = localizedField('pokemon', 'p.id', 'p.details', 'details', locale); const typeName = localizedName('pokemon-types', 'pt', locale); const environmentName = localizedName('environments', 'e', locale); - const environmentOppositeName = localizedName('environments', 'environment_opposite', locale); const skillName = localizedName('skills', 's', locale); const favoriteThingName = localizedName('favorite-things', 'ft', locale); - const favoriteThingOppositeName = localizedName('favorite-things', 'opposite_ft', locale); return ` SELECT @@ -2754,12 +2752,7 @@ function pokemonProjection(locale: string): string { json_build_object( 'id', e.id, 'name', ${environmentName}, - 'description', e.description, - 'opposite', - CASE - WHEN environment_opposite.id IS NULL THEN NULL - ELSE json_build_object('id', environment_opposite.id, 'name', ${environmentOppositeName}) - END + 'description', e.description ) AS environment, COALESCE(( SELECT json_agg(json_build_object('id', pt.id, 'name', ${typeName}) ORDER BY ppt.slot_order) @@ -2777,23 +2770,16 @@ function pokemonProjection(locale: string): string { SELECT json_agg( json_build_object( 'id', ft.id, - 'name', ${favoriteThingName}, - 'opposite', - CASE - WHEN opposite_ft.id IS NULL THEN NULL - ELSE json_build_object('id', opposite_ft.id, 'name', ${favoriteThingOppositeName}) - END + 'name', ${favoriteThingName} ) ORDER BY ${orderByEntity('ft')} ) FROM pokemon_favorite_things pft JOIN favorite_things ft ON ft.id = pft.favorite_thing_id - LEFT JOIN favorite_things opposite_ft ON opposite_ft.id = ft.opposite_favorite_thing_id WHERE pft.pokemon_id = p.id ), '[]'::json) AS favorite_things FROM pokemon p JOIN environments e ON e.id = p.environment_id - LEFT JOIN environments environment_opposite ON environment_opposite.id = e.opposite_environment_id ${auditJoins('p', 'pokemon_created_user', 'pokemon_updated_user')} `; } @@ -5415,6 +5401,69 @@ export function isConfigType(type: string): type is ConfigType { return Object.hasOwn(configDefinitions, type); } +async function syncConfigOpposite( + client: DbClient, + definition: ConfigDefinition, + id: number, + oppositeId: number | null, + userId: number +): Promise { + const oppositeColumn = definition.oppositeColumn; + if (!oppositeColumn) { + return; + } + + if (oppositeId === id) { + throw validationError('server.validation.invalidField'); + } + + if (oppositeId !== null) { + const opposite = await client.query<{ id: number }>( + `SELECT id FROM ${definition.table} WHERE id = $1 FOR UPDATE`, + [oppositeId] + ); + if (opposite.rowCount === 0) { + throw validationError('server.validation.selectRecord'); + } + } + + await client.query( + ` + UPDATE ${definition.table} + SET ${oppositeColumn} = NULL, + updated_by_user_id = $2, + updated_at = now() + WHERE id <> $1 + AND (${oppositeColumn} = $1 OR (${oppositeId === null ? 'FALSE' : `${oppositeColumn} = $3`})) + `, + oppositeId === null ? [id, userId] : [id, userId, oppositeId] + ); + + await client.query( + ` + UPDATE ${definition.table} + SET ${oppositeColumn} = $1, + updated_by_user_id = $2, + updated_at = now() + WHERE id = $3 + `, + [oppositeId, userId, id] + ); + + if (oppositeId !== null) { + await client.query( + ` + UPDATE ${definition.table} + SET ${oppositeColumn} = $1, + updated_by_user_id = $2, + updated_at = now() + WHERE id = $3 + `, + [id, userId, oppositeId] + ); + } +} + export async function listConfig(type: ConfigType, locale = defaultLocale) { const definition = configDefinitions[type]; return query( @@ -5460,10 +5509,6 @@ export async function createConfig(type: ConfigType, payload: Record{{ t('pages.pokemon.environmentCoreNote') }}

{{ pokemon.environment.name }} - - {{ t('pages.pokemon.opposite') }}: {{ pokemon.environment.opposite.name }} -

{{ pokemon.environment.description }}

@@ -802,7 +799,6 @@ watch(initialPokemon, applyInitialPokemon, { immediate: true });
{{ thing.name }} - {{ t('pages.pokemon.opposite') }}: {{ thing.opposite.name }}

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

@@ -887,8 +883,7 @@ watch(initialPokemon, applyInitialPokemon, { immediate: true }); 'related-pokemon-row__environment--opposite': related.environment.isOpposite }" > - {{ related.environment.name }} - {{ t('pages.pokemon.opposite') }} + {{ related.environment.name }} @@ -902,8 +897,7 @@ watch(initialPokemon, applyInitialPokemon, { immediate: true }); class="chip related-favourite-chip" :class="{ 'related-favourite-chip--match': thing.matches, 'related-favourite-chip--opposite': thing.isOpposite }" > - {{ thing.name }} - {{ t('pages.pokemon.opposite') }} + {{ thing.name }} diff --git a/system-wordings.ts b/system-wordings.ts index 42c6fc9..6a6eb92 100644 --- a/system-wordings.ts +++ b/system-wordings.ts @@ -597,7 +597,6 @@ export const systemWordingMessages = { skillsCoreNote: 'Affects habitat selection, item drops, and Trading behavior.', environmentCoreNote: 'Affects habitat selection and related Pokemon comparison.', favoriteThingsCoreNote: 'Affects item drops, hidden item tags, and Trading price evidence.', - opposite: 'Opposite', genus: 'Genus', height: 'Height', heightInput: 'Height (in)', @@ -2022,7 +2021,6 @@ export const systemWordingMessages = { skillsCoreNote: '影响栖息地选择、物品掉落和 Trading 行为。', environmentCoreNote: '影响栖息地选择和相关 Pokemon 对比。', favoriteThingsCoreNote: '影响物品掉落、隐藏标签判断和 Trading 价格证据。', - opposite: '反义', genus: '分类', height: '身高', heightInput: '身高(in)',