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)',