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
{{ pokemon.environment.description }}
@@ -802,7 +799,6 @@ watch(initialPokemon, applyInitialPokemon, { immediate: true });