feat: add pokemon trading preferences and item tag inference

Introduce trading preference (Likes/Neutral) for Pokemon with trading skills
Infer possible hidden tags for items based on trading observations
Update import/export, wipe, and admin config to support trading data
This commit is contained in:
2026-05-05 22:54:32 +08:00
parent 5b22d788d7
commit 22016365d8
12 changed files with 1097 additions and 33 deletions

View File

@@ -214,9 +214,9 @@
- Items 与 Recipes 存在依赖关系;选择 Items 进行导出、导入或 Wipe 时,系统必须自动同时纳入 Recipes前端确认内容也必须显示 Recipes。 - Items 与 Recipes 存在依赖关系;选择 Items 进行导出、导入或 Wipe 时,系统必须自动同时纳入 Recipes前端确认内容也必须显示 Recipes。
- Wipe 行为: - Wipe 行为:
- 删除所选范围的主数据、关联数据、实体翻译、编辑历史、图片上传记录和实体讨论评论。 - 删除所选范围的主数据、关联数据、实体翻译、编辑历史、图片上传记录和实体讨论评论。
- Wipe Pokemon 会删除 Pokemon 及其属性 / 特长 / 喜欢的东西 / 掉落关联,并移除栖息地中的 Pokemon 出现配置,但不删除栖息地本身。 - Wipe Pokemon 会删除 Pokemon 及其属性 / 特长 / 喜欢的东西 / 掉落关联 / Trading 观察,并移除栖息地中的 Pokemon 出现配置,但不删除栖息地本身。
- Wipe Habitats 会删除栖息地、栖息地配方项和 Pokemon 出现配置,但不删除 Pokemon、Items 或 Maps。 - Wipe Habitats 会删除栖息地、栖息地配方项和 Pokemon 出现配置,但不删除 Pokemon、Items 或 Maps。
- Wipe Items 会先删除 Recipes再删除物品、物品入手方式 / 喜欢的东西关联、栖息地配方项Pokemon 掉落关联。 - Wipe Items 会先删除 Recipes再删除物品、物品入手方式 / 喜欢的东西关联、栖息地配方项Pokemon 掉落关联和 Trading 观察
- Wipe Ancient Artifacts 会清空物品上的 Ancient Artifact 分类并删除对应 Ancient Artifact 讨论;物品本身仍保留在 Items / Event Items 中。 - Wipe Ancient Artifacts 会清空物品上的 Ancient Artifact 分类并删除对应 Ancient Artifact 讨论;物品本身仍保留在 Items / Event Items 中。
- Wipe Recipes 会删除材料单、材料项和入手方式关联,但不删除 Items。 - Wipe Recipes 会删除材料单、材料项和入手方式关联,但不删除 Items。
- Wipe Daily CheckList 会删除清单任务和任务翻译 / 编辑历史。 - Wipe Daily CheckList 会删除清单任务和任务翻译 / 编辑历史。
@@ -225,7 +225,7 @@
- 导出为版本化 JSON bundle包含 `version``exportedAt``scopes` 和对应范围数据。 - 导出为版本化 JSON bundle包含 `version``exportedAt``scopes` 和对应范围数据。
- JSON bundle 用于系统导入,不作为前台展示内容。 - JSON bundle 用于系统导入,不作为前台展示内容。
- 导出包含所选范围的主数据、关联数据、实体翻译、编辑历史、图片上传记录和实体讨论评论。 - 导出包含所选范围的主数据、关联数据、实体翻译、编辑历史、图片上传记录和实体讨论评论。
- 导出必须包含对应 Wipe 会移除的跨范围关联行,例如 Pokemon 出现配置、Pokemon 掉落和栖息地配方项;导入这些关联时,引用的另一侧实体必须已存在。 - 导出必须包含对应 Wipe 会移除的跨范围关联行,例如 Pokemon 出现配置、Pokemon 掉落、Trading 观察和栖息地配方项;导入这些关联时,引用的另一侧实体必须已存在。
- JSON 不包含上传文件本身;`backend_uploads` volume 需要单独备份。 - JSON 不包含上传文件本身;`backend_uploads` volume 需要单独备份。
- Import 行为: - Import 行为:
- 当前只支持 Replace selected scopes导入前先 Wipe bundle 中包含的范围,再在同一事务中还原 bundle 数据。 - 当前只支持 Replace selected scopes导入前先 Wipe bundle 中包含的范围,再在同一事务中还原 bundle 数据。
@@ -440,8 +440,10 @@
- 名称 - 名称
- 是否有掉落物:`has_item_drop` - 是否有掉落物:`has_item_drop`
- 是否支持 Trading`has_trading`
- 已移除 `subcategory` 字段。 - 已移除 `subcategory` 字段。
- 当特长允许掉落物时Pokemon 编辑中可为该 Pokemon + 特长配置一个掉落物品。 - 当特长允许掉落物时Pokemon 编辑中可为该 Pokemon + 特长配置一个掉落物品。
- 当 Pokemon 选择了至少一个支持 Trading 的特长时Pokemon 详情页可直接维护该 Pokemon 对物品的 Trading 偏好观察。
### Pokemon Types ### Pokemon Types
@@ -504,6 +506,10 @@ Pokemon 可配置:
- 特长:可多选,最多 2 个 - 特长:可多选,最多 2 个
- 特长掉落物品:按 Pokemon + 特长配置,单选物品 - 特长掉落物品:按 Pokemon + 特长配置,单选物品
- 喜欢的东西:可多选,最多 6 个 - 喜欢的东西:可多选,最多 6 个
- Trading由所选特长是否支持 Trading 决定;当至少一个所选特长支持 Trading 时,可维护该 Pokemon 对物品的 Trading 偏好观察,分为 Likes 与 Neutral
- Likes该 Pokemon 喜欢交易该物品,交易价格触发 1.5x 加成;用于物品隐藏标签推断的正向证据
- Neutral该 Pokemon 对交易该物品无加成;用于物品隐藏标签推断的硬排除证据
- 每个物品在同一个 Pokemon 的 Trading 列表中只能出现一次,只能属于 Likes 或 Neutral 其中一组
- 六维: - 六维:
- HP - HP
- Attack - Attack
@@ -551,6 +557,7 @@ Pokemon 编辑表单使用标签页组织字段:
- 第二行:喜欢的环境、特长 - 第二行:喜欢的环境、特长
- 第三行:喜欢的东西 - 第三行:喜欢的东西
- 特长掉落物品随已选择且支持掉落物的特长显示 - 特长掉落物品随已选择且支持掉落物的特长显示
- 编辑表单不直接维护 Trading 观察Trading 由详情页的 Manage Trading 入口维护
- Pokemon 图片选择区 - Pokemon 图片选择区
- Advance 标签页: - Advance 标签页:
- 第一行Genus - 第一行Genus
@@ -584,7 +591,9 @@ Pokemon 详情页展示:
- 右侧:六维 Stats图片或默认占位符展示在 Stats 右侧 - 右侧:六维 Stats图片或默认占位符展示在 Stats 右侧
- 六维使用 ProgressBar 展示,最大值按 150 计算。 - 六维使用 ProgressBar 展示,最大值按 150 计算。
- 特长 - 特长
- 特长掉落物品:展示掉落物品图标未配置图标时显示默认物品标记占位符 - 特长掉落物品:当该 Pokemon 拥有支持掉落物的已选特长时默认展示;已配置时展示掉落物品图标未配置时展示空状态
- Trading当该 Pokemon 拥有支持 Trading 的已选特长时默认展示;分 Likes 与 Neutral 两组展示物品Likes 表示交易价格 1.5xNeutral 表示无加成,未配置观察时展示空状态
- Trading 可在详情页通过 Manage Trading Modal 维护Modal 左侧顶部为同一行搜索框和分类下拉筛选,下面提供 Likes / Neutral 默认加入目标切换;从左侧选择物品会加入当前默认目标组,右侧按 Likes / Neutral 分组展示已选物品,并可切换分组或移除;列表区域高度稳定,过滤结果减少时 Modal 不应抖动
- 喜欢的环境 - 喜欢的环境
- 喜欢的东西 - 喜欢的东西
- 相关 Pokemon与关联喜欢的东西的物品在桌面端左右并排展示按相同喜欢的环境优先其次按共同喜欢的东西数量从多到少排序支持按喜欢的环境筛选默认筛选当前 Pokemon 的喜欢的环境,也可切换到其他喜欢的环境或全部;每个筛选视图最多展示 6 个;每项左侧展示 Pokemon 图片或默认 Poké Ball 占位符,原列表项信息布局保持不变,第一行左侧展示名称,右侧展示特长和喜欢的环境,第二行展示喜欢的东西,并高亮共同喜欢的东西 - 相关 Pokemon与关联喜欢的东西的物品在桌面端左右并排展示按相同喜欢的环境优先其次按共同喜欢的东西数量从多到少排序支持按喜欢的环境筛选默认筛选当前 Pokemon 的喜欢的环境,也可切换到其他喜欢的环境或全部;每个筛选视图最多展示 6 个;每项左侧展示 Pokemon 图片或默认 Poké Ball 占位符,原列表项信息布局保持不变,第一行左侧展示名称,右侧展示特长和喜欢的环境,第二行展示喜欢的东西,并高亮共同喜欢的东西
@@ -670,6 +679,13 @@ Items 与 Event Items 使用相同数据模型:
- 入手方式 - 入手方式
- 客制化 - 客制化
- 标签 - 标签
- Possible Tags根据所有拥有支持 Trading 特长的 Pokemon Trading 观察推断该物品可能包含的隐藏标签
- 每个 Pokemon 的“喜欢的东西”视为该 Pokemon 已知的 6 个隐藏标签集合;不完整数据仍参与展示,但不会强行补足缺失标签
- 若物品被 Pokemon 标记为 Likes则该物品至少包含该 Pokemon 标签集合中的一个标签,属于 OR 正向证据
- 若物品被 Pokemon 标记为 Neutral则该物品不包含该 Pokemon 标签集合中的任何标签属于硬排除证据Neutral 排除优先于 Likes 正向证据
- 推断流程必须确定性执行:从所有“喜欢的东西 / 标签”开始,先移除所有 Neutral Pokemon 提供的标签,再用 Likes Pokemon 的标签集合收窄候选;多个 Likes 观察的共同候选归为 Highly likely其余正向候选归为 Possible被排除或被约束移出的标签归为 Excluded
- 没有可用 Likes 观察时,未被 Neutral 排除的标签保持 Possible没有任何观察时所有标签保持 Possible
- Possible Tags 区块必须展示 Likes 与 Neutral 证据来源,包含贡献 Pokemon 及其已知标签,不展示内部字段、调试信息或推断中间状态
- 关联材料单:展示结果物品图标和材料物品图标;未配置图标时显示默认物品标记占位符 - 关联材料单:展示结果物品图标和材料物品图标;未配置图标时显示默认物品标记占位符
- 作为材料出现的材料单:展示结果物品图标和材料物品图标;未配置图标时显示默认物品标记占位符 - 作为材料出现的材料单:展示结果物品图标和材料物品图标;未配置图标时显示默认物品标记占位符
- 相关栖息地:展示栖息地图片或默认栖息地标记占位符,并展示配方材料物品图标 - 相关栖息地:展示栖息地图片或默认栖息地标记占位符,并展示配方材料物品图标

View File

@@ -809,6 +809,7 @@ CREATE TABLE IF NOT EXISTS skills (
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
name text NOT NULL UNIQUE, name text NOT NULL UNIQUE,
has_item_drop boolean NOT NULL DEFAULT false, has_item_drop boolean NOT NULL DEFAULT false,
has_trading boolean NOT NULL DEFAULT false,
sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0), sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0),
created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL, created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL,
updated_by_user_id integer REFERENCES users(id) ON DELETE SET NULL, updated_by_user_id integer REFERENCES users(id) ON DELETE SET NULL,
@@ -964,6 +965,16 @@ CREATE TABLE IF NOT EXISTS item_favorite_things (
PRIMARY KEY (item_id, favorite_thing_id) PRIMARY KEY (item_id, favorite_thing_id)
); );
CREATE TABLE IF NOT EXISTS pokemon_trading_items (
pokemon_id integer NOT NULL REFERENCES pokemon(id) ON DELETE CASCADE,
item_id integer NOT NULL REFERENCES items(id) ON DELETE CASCADE,
preference text NOT NULL CHECK (preference IN ('like', 'neutral')),
PRIMARY KEY (pokemon_id, item_id)
);
CREATE INDEX IF NOT EXISTS pokemon_trading_items_item_idx
ON pokemon_trading_items(item_id, preference, pokemon_id);
CREATE TABLE IF NOT EXISTS pokemon_skill_item_drops ( CREATE TABLE IF NOT EXISTS pokemon_skill_item_drops (
pokemon_id integer NOT NULL, pokemon_id integer NOT NULL,
skill_id integer NOT NULL, skill_id integer NOT NULL,
@@ -1262,3 +1273,6 @@ CREATE INDEX IF NOT EXISTS entity_discussion_comments_ai_moderation_status_idx
CREATE INDEX IF NOT EXISTS entity_discussion_comments_ai_moderation_language_idx CREATE INDEX IF NOT EXISTS entity_discussion_comments_ai_moderation_language_idx
ON entity_discussion_comments(entity_type, entity_id, ai_moderation_language_code, created_at, id) ON entity_discussion_comments(entity_type, entity_id, ai_moderation_language_code, created_at, id)
WHERE deleted_at IS NULL; WHERE deleted_at IS NULL;
ALTER TABLE skills
ADD COLUMN IF NOT EXISTS has_trading boolean NOT NULL DEFAULT false;

View File

@@ -98,6 +98,7 @@ type ConfigDefinition = {
table: string; table: string;
entityType: EntityType; entityType: EntityType;
hasItemDrop?: boolean; hasItemDrop?: boolean;
hasTrading?: boolean;
hasDefault?: boolean; hasDefault?: boolean;
hasRateable?: boolean; hasRateable?: boolean;
hasChangeLog?: boolean; hasChangeLog?: boolean;
@@ -150,6 +151,13 @@ type PokemonImageOptionsResult = {
images: PokemonImage[]; images: PokemonImage[];
}; };
type TradingPreference = 'like' | 'neutral';
type PokemonTradingItemPayload = {
itemId: number;
preference: TradingPreference;
};
type PokemonPayload = { type PokemonPayload = {
dataId: number | null; dataId: number | null;
dataIdentifier: string; dataIdentifier: string;
@@ -167,6 +175,7 @@ type PokemonPayload = {
skillIds: number[]; skillIds: number[];
favoriteThingIds: number[]; favoriteThingIds: number[];
skillItemDrops: SkillItemDrop[]; skillItemDrops: SkillItemDrop[];
tradingItems: PokemonTradingItemPayload[];
image: PokemonImage | null; image: PokemonImage | null;
}; };
@@ -540,6 +549,7 @@ type PokemonChangeSource = {
environment: { name: string }; environment: { name: string };
skills: Array<{ name: string; itemDrop?: { name: string } | null }>; skills: Array<{ name: string; itemDrop?: { name: string } | null }>;
favorite_things: Array<{ name: string }>; favorite_things: Array<{ name: string }>;
tradingItems: Array<{ name: string; preference: TradingPreference }>;
} & TranslationChangeSource; } & TranslationChangeSource;
type ItemChangeSource = { type ItemChangeSource = {
name: string; name: string;
@@ -599,6 +609,7 @@ type DailyChecklistChangeSource = {
type ConfigChangeSource = { type ConfigChangeSource = {
name: string; name: string;
hasItemDrop?: boolean; hasItemDrop?: boolean;
hasTrading?: boolean;
isDefault?: boolean; isDefault?: boolean;
isRateable?: boolean; isRateable?: boolean;
changeLog?: string; changeLog?: string;
@@ -663,7 +674,7 @@ const ancientArtifactCategoryOptions = [
const configDefinitions: Record<ConfigType, ConfigDefinition> = { const configDefinitions: Record<ConfigType, ConfigDefinition> = {
'pokemon-types': { table: 'pokemon_types', entityType: 'pokemon-types' }, 'pokemon-types': { table: 'pokemon_types', entityType: 'pokemon-types' },
skills: { table: 'skills', entityType: 'skills', hasItemDrop: true }, skills: { table: 'skills', entityType: 'skills', hasItemDrop: true, hasTrading: true },
environments: { table: 'environments', entityType: 'environments' }, environments: { table: 'environments', entityType: 'environments' },
'favorite-things': { table: 'favorite_things', entityType: 'favorite-things' }, 'favorite-things': { table: 'favorite_things', entityType: 'favorite-things' },
'acquisition-methods': { table: 'acquisition_methods', entityType: 'acquisition-methods' }, 'acquisition-methods': { table: 'acquisition_methods', entityType: 'acquisition-methods' },
@@ -962,9 +973,11 @@ function gameVersionOptions(locale: string): Promise<Array<{ id: number; name: s
return query(`SELECT gv.id, ${name} AS name, gv.change_log AS "changeLog" FROM game_versions gv ORDER BY ${orderByEntity('gv')}`); return query(`SELECT gv.id, ${name} AS name, gv.change_log AS "changeLog" FROM game_versions gv ORDER BY ${orderByEntity('gv')}`);
} }
function skillOptions(locale: string): Promise<Array<{ id: number; name: string; hasItemDrop: boolean }>> { function skillOptions(locale: string): Promise<Array<{ id: number; name: string; hasItemDrop: boolean; hasTrading: boolean }>> {
const name = localizedName('skills', 's', locale); const name = localizedName('skills', 's', locale);
return query(`SELECT s.id, ${name} AS name, s.has_item_drop AS "hasItemDrop" FROM skills s ORDER BY ${orderByEntity('s')}`); return query(
`SELECT s.id, ${name} AS name, s.has_item_drop AS "hasItemDrop", s.has_trading AS "hasTrading" FROM skills s ORDER BY ${orderByEntity('s')}`
);
} }
function auditSelect(entityAlias: string, createdAlias = 'created_user', updatedAlias = 'updated_user'): string { function auditSelect(entityAlias: string, createdAlias = 'created_user', updatedAlias = 'updated_user'): string {
@@ -1000,6 +1013,9 @@ function configSelect(definition: ConfigDefinition, locale: string): string {
if (definition.hasItemDrop) { if (definition.hasItemDrop) {
columns.push(`c.has_item_drop AS "hasItemDrop"`); columns.push(`c.has_item_drop AS "hasItemDrop"`);
} }
if (definition.hasTrading) {
columns.push(`c.has_trading AS "hasTrading"`);
}
if (definition.hasDefault) { if (definition.hasDefault) {
columns.push(`c.is_default AS "isDefault"`); columns.push(`c.is_default AS "isDefault"`);
} }
@@ -1944,7 +1960,7 @@ async function ensurePokemonTypeCatalog(
const changes = configEditChanges( const changes = configEditChanges(
{ table: 'pokemon_types', entityType: 'pokemon-types' }, { table: 'pokemon_types', entityType: 'pokemon-types' },
existing.rows[0], existing.rows[0],
{ name, translations, hasItemDrop: false, isDefault: false, isRateable: false, changeLog: '' } { name, translations, hasItemDrop: false, hasTrading: false, isDefault: false, isRateable: false, changeLog: '' }
); );
if (changes.length) { if (changes.length) {
await client.query( await client.query(
@@ -2239,6 +2255,19 @@ function namesFromIds(ids: number[], namesById: Map<number, string>): string {
return names.length ? names.join(' / ') : 'None'; return names.length ? names.join(' / ') : 'None';
} }
function namedTradingListValue(
rows: Array<{ name: string; preference: TradingPreference }> | null | undefined
): string {
if (!rows?.length) {
return 'None';
}
return rows
.map((row) => `${row.preference === 'like' ? 'Likes' : 'Neutral'}: ${row.name}`)
.sort((a, b) => a.localeCompare(b))
.join(' / ');
}
async function quantityPayloadValue(client: DbClient, rows: IdQuantity[]): Promise<string> { async function quantityPayloadValue(client: DbClient, rows: IdQuantity[]): Promise<string> {
const namesById = await entityNameMap(client, 'items', rows.map((row) => row.itemId)); const namesById = await entityNameMap(client, 'items', rows.map((row) => row.itemId));
return quantityListValue( return quantityListValue(
@@ -2263,6 +2292,15 @@ async function pokemonEditChanges(
const favoriteThingNames = await entityNameMap(client, 'favorite_things', after.favoriteThingIds); const favoriteThingNames = await entityNameMap(client, 'favorite_things', after.favoriteThingIds);
const dropSkillNames = await entityNameMap(client, 'skills', after.skillItemDrops.map((drop) => drop.skillId)); const dropSkillNames = await entityNameMap(client, 'skills', after.skillItemDrops.map((drop) => drop.skillId));
const dropItemNames = await entityNameMap(client, 'items', after.skillItemDrops.map((drop) => drop.itemId)); const dropItemNames = await entityNameMap(client, 'items', after.skillItemDrops.map((drop) => drop.itemId));
const tradingItemNames = await entityNameMap(client, 'items', after.tradingItems.map((item) => item.itemId));
const afterTradingItems = after.tradingItems
.map((item) => {
const itemName = tradingItemNames.get(item.itemId);
return itemName ? `${item.preference === 'like' ? 'Likes' : 'Neutral'}: ${itemName}` : null;
})
.filter((value): value is string => value !== null)
.sort((a, b) => a.localeCompare(b));
const afterTradingItemsValue = afterTradingItems.length ? afterTradingItems.join(' / ') : 'None';
const afterDrops = after.skillItemDrops const afterDrops = after.skillItemDrops
.map((drop) => { .map((drop) => {
const skillName = dropSkillNames.get(drop.skillId); const skillName = dropSkillNames.get(drop.skillId);
@@ -2287,6 +2325,7 @@ async function pokemonEditChanges(
pushChange(changes, 'Ideal Habitat', before.environment.name, environmentNames.get(after.environmentId)); pushChange(changes, 'Ideal Habitat', before.environment.name, environmentNames.get(after.environmentId));
pushChange(changes, 'Specialities', namedListValue(before.skills), namesFromIds(after.skillIds, skillNames)); pushChange(changes, 'Specialities', namedListValue(before.skills), namesFromIds(after.skillIds, skillNames));
pushChange(changes, 'Favourites', namedListValue(before.favorite_things), namesFromIds(after.favoriteThingIds, favoriteThingNames)); pushChange(changes, 'Favourites', namedListValue(before.favorite_things), namesFromIds(after.favoriteThingIds, favoriteThingNames));
pushChange(changes, 'Trading items', namedTradingListValue(before.tradingItems), afterTradingItemsValue);
pushChange(changes, 'Speciality drops', skillDropListValue(before.skills), afterDrops); pushChange(changes, 'Speciality drops', skillDropListValue(before.skills), afterDrops);
return changes; return changes;
@@ -2330,6 +2369,91 @@ async function itemEditChanges(
return changes; return changes;
} }
type ItemPossibleTagEntity = {
id: number;
name: string;
};
type ItemPossibleTagPokemon = {
id: number;
displayId: number;
name: string;
isEventItem: boolean;
image: EntityImageValue | null;
};
type ItemPossibleTagObservation = {
pokemon: ItemPossibleTagPokemon;
preference: TradingPreference;
tags: ItemPossibleTagEntity[];
};
type ItemPossibleTags = {
highlyLikely: ItemPossibleTagEntity[];
possible: ItemPossibleTagEntity[];
excluded: ItemPossibleTagEntity[];
evidence: {
likes: ItemPossibleTagObservation[];
neutral: ItemPossibleTagObservation[];
};
};
function inferItemPossibleTags(
allTags: ItemPossibleTagEntity[],
observations: ItemPossibleTagObservation[]
): ItemPossibleTags {
const allTagIds = new Set(allTags.map((tag) => tag.id));
const neutralExcludedTagIds = new Set<number>();
const filteredLikeSets: number[][] = [];
const likes: ItemPossibleTagObservation[] = [];
const neutral: ItemPossibleTagObservation[] = [];
for (const observation of observations) {
const filteredTagIds = [...new Set(observation.tags.map((tag) => tag.id).filter((id) => allTagIds.has(id)))];
const filteredObservation = {
...observation,
tags: observation.tags.filter((tag) => allTagIds.has(tag.id))
};
if (observation.preference === 'neutral') {
neutral.push(filteredObservation);
filteredTagIds.forEach((id) => neutralExcludedTagIds.add(id));
continue;
}
likes.push(filteredObservation);
if (filteredTagIds.length > 0) {
filteredLikeSets.push(filteredTagIds);
}
}
const allowedTagIds = allTags.map((tag) => tag.id).filter((id) => !neutralExcludedTagIds.has(id));
const conflicts = likes.some((observation) => observation.tags.length > 0 && observation.tags.every((tag) => neutralExcludedTagIds.has(tag.id)));
const unionLikeIds = new Set(filteredLikeSets.flat());
const intersectionLikeIds =
filteredLikeSets.length >= 2
? filteredLikeSets.reduce((result, current) => result.filter((id) => current.includes(id)))
: [];
const candidateTagIds = conflicts
? []
: filteredLikeSets.length > 0
? allowedTagIds.filter((id) => unionLikeIds.has(id))
: allowedTagIds;
const highlyLikelyTagIds = filteredLikeSets.length >= 2
? intersectionLikeIds.filter((id) => candidateTagIds.includes(id))
: [];
const possibleTagIds = candidateTagIds.filter((id) => !highlyLikelyTagIds.includes(id));
const excludedTagIds = allTags.map((tag) => tag.id).filter((id) => !candidateTagIds.includes(id));
const tagsById = new Map(allTags.map((tag) => [tag.id, tag]));
return {
highlyLikely: highlyLikelyTagIds.map((id) => tagsById.get(id)).filter((tag): tag is ItemPossibleTagEntity => Boolean(tag)),
possible: possibleTagIds.map((id) => tagsById.get(id)).filter((tag): tag is ItemPossibleTagEntity => Boolean(tag)),
excluded: excludedTagIds.map((id) => tagsById.get(id)).filter((tag): tag is ItemPossibleTagEntity => Boolean(tag)),
evidence: { likes, neutral }
};
}
async function ancientArtifactEditChanges( async function ancientArtifactEditChanges(
client: DbClient, client: DbClient,
before: AncientArtifactChangeSource, before: AncientArtifactChangeSource,
@@ -2405,6 +2529,7 @@ function configEditChanges(
name: string; name: string;
translations: TranslationInput; translations: TranslationInput;
hasItemDrop: boolean; hasItemDrop: boolean;
hasTrading: boolean;
isDefault: boolean; isDefault: boolean;
isRateable: boolean; isRateable: boolean;
changeLog: string; changeLog: string;
@@ -2416,6 +2541,9 @@ function configEditChanges(
if (definition.hasItemDrop) { if (definition.hasItemDrop) {
pushChange(changes, 'Has item drop', boolValue(Boolean(before.hasItemDrop)), boolValue(after.hasItemDrop)); pushChange(changes, 'Has item drop', boolValue(Boolean(before.hasItemDrop)), boolValue(after.hasItemDrop));
} }
if (definition.hasTrading) {
pushChange(changes, 'Has trading', boolValue(Boolean(before.hasTrading)), boolValue(after.hasTrading));
}
if (definition.hasDefault) { if (definition.hasDefault) {
pushChange(changes, 'Default category', boolValue(Boolean(before.isDefault)), boolValue(after.isDefault)); pushChange(changes, 'Default category', boolValue(Boolean(before.isDefault)), boolValue(after.isDefault));
} }
@@ -2494,7 +2622,7 @@ function pokemonProjection(locale: string): string {
WHERE ppt.pokemon_id = p.id WHERE ppt.pokemon_id = p.id
), '[]'::json) AS types, ), '[]'::json) AS types,
COALESCE(( COALESCE((
SELECT json_agg(json_build_object('id', s.id, 'name', ${skillName}, 'hasItemDrop', s.has_item_drop) ORDER BY ${orderByEntity('s')}) SELECT json_agg(json_build_object('id', s.id, 'name', ${skillName}, 'hasItemDrop', s.has_item_drop, 'hasTrading', s.has_trading) ORDER BY ${orderByEntity('s')})
FROM pokemon_skills ps FROM pokemon_skills ps
JOIN skills s ON s.id = ps.skill_id JOIN skills s ON s.id = ps.skill_id
WHERE ps.pokemon_id = p.id WHERE ps.pokemon_id = p.id
@@ -5343,6 +5471,7 @@ export async function createConfig(type: ConfigType, payload: Record<string, unk
const name = cleanName(payload.name); const name = cleanName(payload.name);
const translations = cleanTranslations(payload.translations, ['name']); const translations = cleanTranslations(payload.translations, ['name']);
const hasItemDrop = definition.hasItemDrop ? Boolean(payload.hasItemDrop) : false; const hasItemDrop = definition.hasItemDrop ? Boolean(payload.hasItemDrop) : false;
const hasTrading = definition.hasTrading ? Boolean(payload.hasTrading) : false;
const isDefault = definition.hasDefault ? Boolean(payload.isDefault) : false; const isDefault = definition.hasDefault ? Boolean(payload.isDefault) : false;
const isRateable = definition.hasRateable ? Boolean(payload.isRateable) : false; const isRateable = definition.hasRateable ? Boolean(payload.isRateable) : false;
const changeLog = definition.hasChangeLog ? cleanOptionalText(payload.changeLog) : ''; const changeLog = definition.hasChangeLog ? cleanOptionalText(payload.changeLog) : '';
@@ -5361,6 +5490,10 @@ export async function createConfig(type: ConfigType, payload: Record<string, unk
columns.push('has_item_drop'); columns.push('has_item_drop');
values.push(hasItemDrop); values.push(hasItemDrop);
} }
if (definition.hasTrading) {
columns.push('has_trading');
values.push(hasTrading);
}
if (definition.hasDefault) { if (definition.hasDefault) {
columns.push('is_default'); columns.push('is_default');
values.push(isDefault); values.push(isDefault);
@@ -5419,6 +5552,7 @@ export async function updateConfig(
const name = cleanName(payload.name); const name = cleanName(payload.name);
const translations = cleanTranslations(payload.translations, ['name']); const translations = cleanTranslations(payload.translations, ['name']);
const hasItemDrop = definition.hasItemDrop ? Boolean(payload.hasItemDrop) : false; const hasItemDrop = definition.hasItemDrop ? Boolean(payload.hasItemDrop) : false;
const hasTrading = definition.hasTrading ? Boolean(payload.hasTrading) : false;
const isDefault = definition.hasDefault ? Boolean(payload.isDefault) : false; const isDefault = definition.hasDefault ? Boolean(payload.isDefault) : false;
const isRateable = definition.hasRateable ? Boolean(payload.isRateable) : false; const isRateable = definition.hasRateable ? Boolean(payload.isRateable) : false;
const changeLog = definition.hasChangeLog ? cleanOptionalText(payload.changeLog) : ''; const changeLog = definition.hasChangeLog ? cleanOptionalText(payload.changeLog) : '';
@@ -5438,6 +5572,10 @@ export async function updateConfig(
values.push(hasItemDrop); values.push(hasItemDrop);
assignments.push(`has_item_drop = $${values.length}`); assignments.push(`has_item_drop = $${values.length}`);
} }
if (definition.hasTrading) {
values.push(hasTrading);
assignments.push(`has_trading = $${values.length}`);
}
if (definition.hasDefault) { if (definition.hasDefault) {
values.push(isDefault); values.push(isDefault);
assignments.push(`is_default = $${values.length}`); assignments.push(`is_default = $${values.length}`);
@@ -5469,10 +5607,31 @@ export async function updateConfig(
if (definition.hasItemDrop && !hasItemDrop) { if (definition.hasItemDrop && !hasItemDrop) {
await client.query('DELETE FROM pokemon_skill_item_drops WHERE skill_id = $1', [id]); await client.query('DELETE FROM pokemon_skill_item_drops WHERE skill_id = $1', [id]);
} }
if (definition.hasTrading && !hasTrading) {
await client.query(
`
DELETE FROM pokemon_trading_items pti
WHERE EXISTS (
SELECT 1
FROM pokemon_skills ps
WHERE ps.pokemon_id = pti.pokemon_id
AND ps.skill_id = $1
)
AND NOT EXISTS (
SELECT 1
FROM pokemon_skills ps
JOIN skills s ON s.id = ps.skill_id
WHERE ps.pokemon_id = pti.pokemon_id
AND s.has_trading = true
)
`,
[id]
);
}
await replaceEntityTranslations(client, definition.entityType, id, translations, ['name']); await replaceEntityTranslations(client, definition.entityType, id, translations, ['name']);
const changes = before const changes = before
? configEditChanges(definition, before as ConfigChangeSource, { name, translations, hasItemDrop, isDefault, isRateable, changeLog }) ? configEditChanges(definition, before as ConfigChangeSource, { name, translations, hasItemDrop, hasTrading, isDefault, isRateable, changeLog })
: []; : [];
await recordEditLog(client, type, id, 'update', userId, changes); await recordEditLog(client, type, id, 'update', userId, changes);
return true; return true;
@@ -5600,8 +5759,9 @@ export async function getPokemon(id: number, locale = defaultLocale) {
const relatedEnvironmentName = localizedName('environments', 'related_environment', locale); const relatedEnvironmentName = localizedName('environments', 'related_environment', locale);
const relatedSkillName = localizedName('skills', 'related_skill', locale); const relatedSkillName = localizedName('skills', 'related_skill', locale);
const relatedFavoriteThingName = localizedName('favorite-things', 'related_favorite_thing', locale); const relatedFavoriteThingName = localizedName('favorite-things', 'related_favorite_thing', locale);
const tradingItemName = localizedName('items', 'trading_item', locale);
const [habitats, itemDrops, favoriteThingItems, relatedPokemon, editHistory, imageHistory] = await Promise.all([ const [habitats, itemDrops, favoriteThingItems, tradingItems, relatedPokemon, editHistory, imageHistory] = await Promise.all([
query( query(
` `
SELECT SELECT
@@ -5650,6 +5810,28 @@ export async function getPokemon(id: number, locale = defaultLocale) {
`, `,
[id] [id]
), ),
query(
`
SELECT
ti.item_id AS "itemId",
ti.preference,
trading_item.id,
${tradingItemName} AS name,
${uploadedImageJson('trading_item.image_path')} AS image
FROM pokemon_trading_items ti
JOIN items trading_item ON trading_item.id = ti.item_id
WHERE ti.pokemon_id = $1
AND EXISTS (
SELECT 1
FROM pokemon_skills ps
JOIN skills trading_skill ON trading_skill.id = ps.skill_id
WHERE ps.pokemon_id = ti.pokemon_id
AND trading_skill.has_trading = true
)
ORDER BY ti.preference DESC, ${orderByEntity('trading_item')}
`,
[id]
),
query( query(
` `
WITH current_pokemon AS ( WITH current_pokemon AS (
@@ -5690,7 +5872,8 @@ export async function getPokemon(id: number, locale = defaultLocale) {
json_build_object( json_build_object(
'id', related_skill.id, 'id', related_skill.id,
'name', ${relatedSkillName}, 'name', ${relatedSkillName},
'hasItemDrop', related_skill.has_item_drop 'hasItemDrop', related_skill.has_item_drop,
'hasTrading', related_skill.has_trading
) )
ORDER BY ${orderByEntity('related_skill')} ORDER BY ${orderByEntity('related_skill')}
) )
@@ -5731,6 +5914,14 @@ export async function getPokemon(id: number, locale = defaultLocale) {
return itemsBySkill; return itemsBySkill;
}, new Map<number, { id: number; name: string; image: EntityImageValue | null }>()); }, new Map<number, { id: number; name: string; image: EntityImageValue | null }>());
const tradingItemsByPreference = tradingItems.map((item) => ({
itemId: item.itemId,
preference: item.preference,
id: item.id,
name: item.name,
image: item.image
}));
const skills = Array.isArray(pokemon.skills) const skills = Array.isArray(pokemon.skills)
? pokemon.skills.map((skill: { id: number; name: string }) => ({ ? pokemon.skills.map((skill: { id: number; name: string }) => ({
...skill, ...skill,
@@ -5738,7 +5929,7 @@ export async function getPokemon(id: number, locale = defaultLocale) {
})) }))
: []; : [];
return { ...pokemon, skills, habitats, favoriteThingItems, relatedPokemon, editHistory, imageHistory }; return { ...pokemon, skills, habitats, favoriteThingItems, tradingItems: tradingItemsByPreference, relatedPokemon, editHistory, imageHistory };
} }
function cleanPokemonPayload(payload: Record<string, unknown>): PokemonPayload { function cleanPokemonPayload(payload: Record<string, unknown>): PokemonPayload {
@@ -5748,6 +5939,7 @@ function cleanPokemonPayload(payload: Record<string, unknown>): PokemonPayload {
const favoriteThingIds = cleanIds(payload.favoriteThingIds); const favoriteThingIds = cleanIds(payload.favoriteThingIds);
const selectedSkillIds = new Set(skillIds); const selectedSkillIds = new Set(skillIds);
const skillItemDrops = new Map<string, SkillItemDrop>(); const skillItemDrops = new Map<string, SkillItemDrop>();
const tradingItems = new Map<string, PokemonTradingItemPayload>();
if (typeIds.length === 0) { if (typeIds.length === 0) {
throw validationError('server.validation.typeMin'); throw validationError('server.validation.typeMin');
@@ -5762,6 +5954,20 @@ function cleanPokemonPayload(payload: Record<string, unknown>): PokemonPayload {
throw validationError('server.validation.favoriteMax'); throw validationError('server.validation.favoriteMax');
} }
if (Array.isArray(payload.tradingItems)) {
for (const item of payload.tradingItems) {
const row = item as Record<string, unknown>;
const itemId = Number(row.itemId);
const preference = row.preference;
if (!Number.isInteger(itemId) || itemId <= 0 || (preference !== 'like' && preference !== 'neutral')) {
throw validationError('server.validation.invalidField');
}
tradingItems.set(String(itemId), { itemId, preference });
}
}
if (Array.isArray(payload.skillItemDrops)) { if (Array.isArray(payload.skillItemDrops)) {
for (const item of payload.skillItemDrops) { for (const item of payload.skillItemDrops) {
const row = item as Record<string, unknown>; const row = item as Record<string, unknown>;
@@ -5799,6 +6005,7 @@ function cleanPokemonPayload(payload: Record<string, unknown>): PokemonPayload {
skillIds, skillIds,
favoriteThingIds, favoriteThingIds,
skillItemDrops: [...skillItemDrops.values()], skillItemDrops: [...skillItemDrops.values()],
tradingItems: [...tradingItems.values()],
image: cleanPokemonImage(payload.imagePath, displayId) image: cleanPokemonImage(payload.imagePath, displayId)
}; };
} }
@@ -5823,6 +6030,7 @@ async function replacePokemonRelations(client: DbClient, pokemonId: number, payl
await client.query('DELETE FROM pokemon_pokemon_types WHERE pokemon_id = $1', [pokemonId]); await client.query('DELETE FROM pokemon_pokemon_types WHERE pokemon_id = $1', [pokemonId]);
await client.query('DELETE FROM pokemon_skills WHERE pokemon_id = $1', [pokemonId]); await client.query('DELETE FROM pokemon_skills WHERE pokemon_id = $1', [pokemonId]);
await client.query('DELETE FROM pokemon_favorite_things WHERE pokemon_id = $1', [pokemonId]); await client.query('DELETE FROM pokemon_favorite_things WHERE pokemon_id = $1', [pokemonId]);
await client.query('DELETE FROM pokemon_trading_items WHERE pokemon_id = $1', [pokemonId]);
for (const [index, typeId] of payload.typeIds.entries()) { for (const [index, typeId] of payload.typeIds.entries()) {
await client.query('INSERT INTO pokemon_pokemon_types (pokemon_id, type_id, slot_order) VALUES ($1, $2, $3)', [ await client.query('INSERT INTO pokemon_pokemon_types (pokemon_id, type_id, slot_order) VALUES ($1, $2, $3)', [
@@ -5843,6 +6051,21 @@ async function replacePokemonRelations(client: DbClient, pokemonId: number, payl
]); ]);
} }
const tradingSkillResult = payload.skillIds.length
? await client.query<{ id: number }>('SELECT id FROM skills WHERE id = ANY($1::integer[]) AND has_trading = true', [payload.skillIds])
: { rows: [] };
const hasTradingSkill = tradingSkillResult.rows.length > 0;
if (hasTradingSkill) {
for (const tradingItem of payload.tradingItems) {
await client.query('INSERT INTO pokemon_trading_items (pokemon_id, item_id, preference) VALUES ($1, $2, $3)', [
pokemonId,
tradingItem.itemId,
tradingItem.preference
]);
}
}
if (payload.skillItemDrops.length > 0) { if (payload.skillItemDrops.length > 0) {
const allowedDrops = await client.query<{ id: number }>( const allowedDrops = await client.query<{ id: number }>(
'SELECT id FROM skills WHERE id = ANY($1::integer[]) AND has_item_drop = true', 'SELECT id FROM skills WHERE id = ANY($1::integer[]) AND has_item_drop = true',
@@ -6414,8 +6637,20 @@ export async function getItem(id: number, locale = defaultLocale) {
const recipeItemName = localizedName('items', 'recipe_item', locale); const recipeItemName = localizedName('items', 'recipe_item', locale);
const pokemonName = localizedName('pokemon', 'p', locale); const pokemonName = localizedName('pokemon', 'p', locale);
const skillName = localizedName('skills', 's', locale); const skillName = localizedName('skills', 's', locale);
const possibleTagName = localizedName('favorite-things', 'possible_tag', locale);
const evidenceTagName = localizedName('favorite-things', 'evidence_tag', locale);
const [acquisitionMethods, recipe, relatedRecipes, relatedHabitats, droppedByPokemon, editHistory, imageHistory] = await Promise.all([ const [
acquisitionMethods,
recipe,
relatedRecipes,
relatedHabitats,
droppedByPokemon,
allPossibleTags,
possibleTagObservations,
editHistory,
imageHistory
] = await Promise.all([
query( query(
` `
SELECT am.id, ${acquisitionMethodName} AS name SELECT am.id, ${acquisitionMethodName} AS name
@@ -6544,11 +6779,50 @@ export async function getItem(id: number, locale = defaultLocale) {
`, `,
[id] [id]
), ),
query<ItemPossibleTagEntity>(
`
SELECT possible_tag.id, ${possibleTagName} AS name
FROM favorite_things possible_tag
ORDER BY ${orderByEntity('possible_tag')}
`
),
query<ItemPossibleTagObservation>(
`
SELECT
json_build_object(
'id', p.id,
'displayId', p.display_id,
'name', ${pokemonName},
'isEventItem', p.is_event_item,
'image', ${pokemonImageJson('p')}
) AS pokemon,
pti.preference,
COALESCE((
SELECT json_agg(json_build_object('id', evidence_tag.id, 'name', ${evidenceTagName}) ORDER BY ${orderByEntity('evidence_tag')})
FROM pokemon_favorite_things pft
JOIN favorite_things evidence_tag ON evidence_tag.id = pft.favorite_thing_id
WHERE pft.pokemon_id = p.id
), '[]'::json) AS tags
FROM pokemon_trading_items pti
JOIN pokemon p ON p.id = pti.pokemon_id
WHERE pti.item_id = $1
AND EXISTS (
SELECT 1
FROM pokemon_skills ps
JOIN skills trading_skill ON trading_skill.id = ps.skill_id
WHERE ps.pokemon_id = p.id
AND trading_skill.has_trading = true
)
ORDER BY pti.preference DESC, ${orderByEntity('p')}
`,
[id]
),
getEditHistory('items', id), getEditHistory('items', id),
listEntityImageUploads('items', id) listEntityImageUploads('items', id)
]); ]);
return { ...item, acquisitionMethods, recipe, relatedRecipes, relatedHabitats, droppedByPokemon, editHistory, imageHistory }; const possibleTags = inferItemPossibleTags(allPossibleTags, possibleTagObservations);
return { ...item, acquisitionMethods, recipe, relatedRecipes, relatedHabitats, droppedByPokemon, possibleTags, editHistory, imageHistory };
} }
function cleanItemPayload(payload: Record<string, unknown>): ItemPayload { function cleanItemPayload(payload: Record<string, unknown>): ItemPayload {
@@ -7236,7 +7510,7 @@ function dishCategoryProjection(locale: string): string {
), '[]'::json), ), '[]'::json),
'pokemonSkill', CASE 'pokemonSkill', CASE
WHEN dish_skill.id IS NULL THEN NULL WHEN dish_skill.id IS NULL THEN NULL
ELSE json_build_object('id', dish_skill.id, 'name', ${skillName}, 'hasItemDrop', dish_skill.has_item_drop) ELSE json_build_object('id', dish_skill.id, 'name', ${skillName}, 'hasItemDrop', dish_skill.has_item_drop, 'hasTrading', dish_skill.has_trading)
END END
) )
ORDER BY d.sort_order, d.id ORDER BY d.sort_order, d.id
@@ -7294,7 +7568,7 @@ function dishProjection(locale: string): string {
), '[]'::json) AS "secondaryMaterials", ), '[]'::json) AS "secondaryMaterials",
CASE CASE
WHEN dish_skill.id IS NULL THEN NULL WHEN dish_skill.id IS NULL THEN NULL
ELSE json_build_object('id', dish_skill.id, 'name', ${skillName}, 'hasItemDrop', dish_skill.has_item_drop) ELSE json_build_object('id', dish_skill.id, 'name', ${skillName}, 'hasItemDrop', dish_skill.has_item_drop, 'hasTrading', dish_skill.has_trading)
END AS "pokemonSkill" END AS "pokemonSkill"
FROM dishes d FROM dishes d
JOIN dish_categories dc ON dc.id = d.category_id JOIN dish_categories dc ON dc.id = d.category_id
@@ -7682,6 +7956,7 @@ const dataToolColumns = {
pokemonSkills: ['pokemon_id', 'skill_id'], pokemonSkills: ['pokemon_id', 'skill_id'],
pokemonFavoriteThings: ['pokemon_id', 'favorite_thing_id'], pokemonFavoriteThings: ['pokemon_id', 'favorite_thing_id'],
pokemonSkillItemDrops: ['pokemon_id', 'skill_id', 'item_id'], pokemonSkillItemDrops: ['pokemon_id', 'skill_id', 'item_id'],
pokemonTradingItems: ['pokemon_id', 'item_id', 'preference'],
habitats: ['id', 'name', 'is_event_item', 'image_path', 'sort_order', 'created_by_user_id', 'updated_by_user_id', 'created_at', 'updated_at'], habitats: ['id', 'name', 'is_event_item', 'image_path', 'sort_order', 'created_by_user_id', 'updated_by_user_id', 'created_at', 'updated_at'],
habitatRecipeItems: ['habitat_id', 'item_id', 'quantity'], habitatRecipeItems: ['habitat_id', 'item_id', 'quantity'],
habitatPokemon: ['habitat_id', 'pokemon_id', 'map_id', 'time_of_day', 'weather', 'rarity'], habitatPokemon: ['habitat_id', 'pokemon_id', 'map_id', 'time_of_day', 'weather', 'rarity'],
@@ -7909,10 +8184,14 @@ function normalizeImportValue(value: unknown): unknown {
return value === undefined ? null : value; return value === undefined ? null : value;
} }
function normalizeImportColumnValue(row: Record<string, unknown>, column: string): unknown {
return normalizeImportValue(row[column]);
}
async function insertRows(client: DbClient, tableName: string, columns: readonly string[], rows: DataToolRows): Promise<void> { async function insertRows(client: DbClient, tableName: string, columns: readonly string[], rows: DataToolRows): Promise<void> {
for (const row of rows) { for (const row of rows) {
const placeholders = columns.map((_, index) => `$${index + 1}`).join(', '); const placeholders = columns.map((_, index) => `$${index + 1}`).join(', ');
const values = columns.map((column) => normalizeImportValue(row[column])); const values = columns.map((column) => normalizeImportColumnValue(row, column));
await client.query(`INSERT INTO ${tableName} (${columns.join(', ')}) VALUES (${placeholders})`, values); await client.query(`INSERT INTO ${tableName} (${columns.join(', ')}) VALUES (${placeholders})`, values);
} }
} }
@@ -7922,7 +8201,7 @@ async function upsertRowsById(client: DbClient, tableName: string, columns: read
for (const row of rows) { for (const row of rows) {
const placeholders = columns.map((_, index) => `$${index + 1}`).join(', '); const placeholders = columns.map((_, index) => `$${index + 1}`).join(', ');
const assignments = updateColumns.map((column) => `${column} = EXCLUDED.${column}`).join(', '); const assignments = updateColumns.map((column) => `${column} = EXCLUDED.${column}`).join(', ');
const values = columns.map((column) => normalizeImportValue(row[column])); const values = columns.map((column) => normalizeImportColumnValue(row, column));
await client.query( await client.query(
`INSERT INTO ${tableName} (${columns.join(', ')}) VALUES (${placeholders}) ON CONFLICT (id) DO UPDATE SET ${assignments}`, `INSERT INTO ${tableName} (${columns.join(', ')}) VALUES (${placeholders}) ON CONFLICT (id) DO UPDATE SET ${assignments}`,
values values
@@ -7933,7 +8212,7 @@ async function upsertRowsById(client: DbClient, tableName: string, columns: read
async function insertRowsIgnoreConflicts(client: DbClient, tableName: string, columns: readonly string[], rows: DataToolRows): Promise<void> { async function insertRowsIgnoreConflicts(client: DbClient, tableName: string, columns: readonly string[], rows: DataToolRows): Promise<void> {
for (const row of rows) { for (const row of rows) {
const placeholders = columns.map((_, index) => `$${index + 1}`).join(', '); const placeholders = columns.map((_, index) => `$${index + 1}`).join(', ');
const values = columns.map((column) => normalizeImportValue(row[column])); const values = columns.map((column) => normalizeImportColumnValue(row, column));
await client.query(`INSERT INTO ${tableName} (${columns.join(', ')}) VALUES (${placeholders}) ON CONFLICT DO NOTHING`, values); await client.query(`INSERT INTO ${tableName} (${columns.join(', ')}) VALUES (${placeholders}) ON CONFLICT DO NOTHING`, values);
} }
} }
@@ -7984,6 +8263,7 @@ async function wipeItemsData(client: DbClient): Promise<void> {
await client.query('DELETE FROM item_favorite_things'); await client.query('DELETE FROM item_favorite_things');
await client.query('DELETE FROM habitat_recipe_items'); await client.query('DELETE FROM habitat_recipe_items');
await client.query('DELETE FROM pokemon_skill_item_drops'); await client.query('DELETE FROM pokemon_skill_item_drops');
await client.query('DELETE FROM pokemon_trading_items');
await client.query('DELETE FROM items'); await client.query('DELETE FROM items');
} }
@@ -8008,6 +8288,7 @@ async function wipePokemonData(client: DbClient): Promise<void> {
await client.query('DELETE FROM pokemon_pokemon_types'); await client.query('DELETE FROM pokemon_pokemon_types');
await client.query('DELETE FROM pokemon_skills'); await client.query('DELETE FROM pokemon_skills');
await client.query('DELETE FROM pokemon_favorite_things'); await client.query('DELETE FROM pokemon_favorite_things');
await client.query('DELETE FROM pokemon_trading_items');
await client.query('DELETE FROM pokemon'); await client.query('DELETE FROM pokemon');
} }
@@ -8089,6 +8370,7 @@ async function exportScopeData(client: DbClient, scope: DataToolScope): Promise<
pokemonSkills: await tableRows(client, 'SELECT * FROM pokemon_skills ORDER BY pokemon_id, skill_id'), pokemonSkills: await tableRows(client, 'SELECT * FROM pokemon_skills ORDER BY pokemon_id, skill_id'),
pokemonFavoriteThings: await tableRows(client, 'SELECT * FROM pokemon_favorite_things ORDER BY pokemon_id, favorite_thing_id'), pokemonFavoriteThings: await tableRows(client, 'SELECT * FROM pokemon_favorite_things ORDER BY pokemon_id, favorite_thing_id'),
pokemonSkillItemDrops: await tableRows(client, 'SELECT * FROM pokemon_skill_item_drops ORDER BY pokemon_id, skill_id'), pokemonSkillItemDrops: await tableRows(client, 'SELECT * FROM pokemon_skill_item_drops ORDER BY pokemon_id, skill_id'),
pokemonTradingItems: await tableRows(client, 'SELECT * FROM pokemon_trading_items ORDER BY pokemon_id, preference, item_id'),
habitatPokemon: await tableRows(client, 'SELECT * FROM habitat_pokemon ORDER BY habitat_id, pokemon_id, map_id, time_of_day, weather'), habitatPokemon: await tableRows(client, 'SELECT * FROM habitat_pokemon ORDER BY habitat_id, pokemon_id, map_id, time_of_day, weather'),
...(await exportGenericScopeData(client, 'pokemon', true)) ...(await exportGenericScopeData(client, 'pokemon', true))
}; };
@@ -8109,6 +8391,7 @@ async function exportScopeData(client: DbClient, scope: DataToolScope): Promise<
itemAcquisitionMethods: await tableRows(client, 'SELECT * FROM item_acquisition_methods ORDER BY item_id, acquisition_method_id'), itemAcquisitionMethods: await tableRows(client, 'SELECT * FROM item_acquisition_methods ORDER BY item_id, acquisition_method_id'),
itemFavoriteThings: await tableRows(client, 'SELECT * FROM item_favorite_things ORDER BY item_id, favorite_thing_id'), itemFavoriteThings: await tableRows(client, 'SELECT * FROM item_favorite_things ORDER BY item_id, favorite_thing_id'),
pokemonSkillItemDrops: await tableRows(client, 'SELECT * FROM pokemon_skill_item_drops ORDER BY pokemon_id, skill_id'), pokemonSkillItemDrops: await tableRows(client, 'SELECT * FROM pokemon_skill_item_drops ORDER BY pokemon_id, skill_id'),
pokemonTradingItems: await tableRows(client, 'SELECT * FROM pokemon_trading_items ORDER BY pokemon_id, preference, item_id'),
habitatRecipeItems: await tableRows(client, 'SELECT * FROM habitat_recipe_items ORDER BY habitat_id, item_id'), habitatRecipeItems: await tableRows(client, 'SELECT * FROM habitat_recipe_items ORDER BY habitat_id, item_id'),
...(await exportGenericScopeData(client, 'items', true)) ...(await exportGenericScopeData(client, 'items', true))
}; };
@@ -8229,6 +8512,7 @@ async function importScopeRelationRows(client: DbClient, bundle: DataToolsBundle
const habitatData = bundle.data.habitats; const habitatData = bundle.data.habitats;
const recipeData = bundle.data.recipes; const recipeData = bundle.data.recipes;
const pokemonDropData = dataToolDataWithRows('pokemonSkillItemDrops', pokemonData, itemData); const pokemonDropData = dataToolDataWithRows('pokemonSkillItemDrops', pokemonData, itemData);
const pokemonTradingData = dataToolDataWithRows('pokemonTradingItems', pokemonData, itemData);
const habitatRecipeData = dataToolDataWithRows('habitatRecipeItems', habitatData, itemData); const habitatRecipeData = dataToolDataWithRows('habitatRecipeItems', habitatData, itemData);
const habitatPokemonData = dataToolDataWithRows('habitatPokemon', habitatData, pokemonData); const habitatPokemonData = dataToolDataWithRows('habitatPokemon', habitatData, pokemonData);
@@ -8239,6 +8523,7 @@ async function importScopeRelationRows(client: DbClient, bundle: DataToolsBundle
await insertRows(client, 'pokemon_skills', dataToolColumns.pokemonSkills, dataToolTableRows(pokemonData, 'pokemonSkills')); await insertRows(client, 'pokemon_skills', dataToolColumns.pokemonSkills, dataToolTableRows(pokemonData, 'pokemonSkills'));
await insertRows(client, 'pokemon_favorite_things', dataToolColumns.pokemonFavoriteThings, dataToolTableRows(pokemonData, 'pokemonFavoriteThings')); await insertRows(client, 'pokemon_favorite_things', dataToolColumns.pokemonFavoriteThings, dataToolTableRows(pokemonData, 'pokemonFavoriteThings'));
await insertRows(client, 'pokemon_skill_item_drops', dataToolColumns.pokemonSkillItemDrops, dataToolTableRows(pokemonDropData, 'pokemonSkillItemDrops')); await insertRows(client, 'pokemon_skill_item_drops', dataToolColumns.pokemonSkillItemDrops, dataToolTableRows(pokemonDropData, 'pokemonSkillItemDrops'));
await insertRows(client, 'pokemon_trading_items', dataToolColumns.pokemonTradingItems, dataToolTableRows(pokemonTradingData, 'pokemonTradingItems'));
await insertRows(client, 'recipe_acquisition_methods', dataToolColumns.recipeAcquisitionMethods, dataToolTableRows(recipeData, 'recipeAcquisitionMethods')); await insertRows(client, 'recipe_acquisition_methods', dataToolColumns.recipeAcquisitionMethods, dataToolTableRows(recipeData, 'recipeAcquisitionMethods'));
await insertRows(client, 'recipe_materials', dataToolColumns.recipeMaterials, dataToolTableRows(recipeData, 'recipeMaterials')); await insertRows(client, 'recipe_materials', dataToolColumns.recipeMaterials, dataToolTableRows(recipeData, 'recipeMaterials'));
await insertRows(client, 'habitat_recipe_items', dataToolColumns.habitatRecipeItems, dataToolTableRows(habitatRecipeData, 'habitatRecipeItems')); await insertRows(client, 'habitat_recipe_items', dataToolColumns.habitatRecipeItems, dataToolTableRows(habitatRecipeData, 'habitatRecipeItems'));

View File

@@ -45,6 +45,8 @@ const changeLabelKeys: Record<string, string> = {
'Speciality drops': 'pages.pokemon.skillDrops', 'Speciality drops': 'pages.pokemon.skillDrops',
'Skill drops': 'pages.pokemon.skillDrops', 'Skill drops': 'pages.pokemon.skillDrops',
特长掉落物: 'pages.pokemon.skillDrops', 特长掉落物: 'pages.pokemon.skillDrops',
Trading: 'pages.pokemon.trading',
'Trading items': 'pages.pokemon.tradingItems',
Category: 'pages.items.category', Category: 'pages.items.category',
分类: 'pages.items.category', 分类: 'pages.items.category',
Usage: 'pages.items.usage', Usage: 'pages.items.usage',
@@ -76,6 +78,8 @@ const changeLabelKeys: Record<string, string> = {
排序: 'pages.admin.sortOrder', 排序: 'pages.admin.sortOrder',
'Has item drop': 'pages.admin.hasItemDrop', 'Has item drop': 'pages.admin.hasItemDrop',
有掉落物: 'pages.admin.hasItemDrop', 有掉落物: 'pages.admin.hasItemDrop',
'Has trading': 'pages.admin.hasTrading',
'有 Trading': 'pages.admin.hasTrading',
'Default category': 'pages.admin.defaultCategory', 'Default category': 'pages.admin.defaultCategory',
默认分类: 'pages.admin.defaultCategory', 默认分类: 'pages.admin.defaultCategory',
Rateable: 'pages.admin.rateableCategory', Rateable: 'pages.admin.rateableCategory',

View File

@@ -1,3 +1,7 @@
<script lang="ts">
let openModalCount = 0;
</script>
<script setup lang="ts"> <script setup lang="ts">
import { Icon } from '@iconify/vue'; import { Icon } from '@iconify/vue';
import { nextTick, onBeforeUnmount, onMounted, onUpdated, ref, watch } from 'vue'; import { nextTick, onBeforeUnmount, onMounted, onUpdated, ref, watch } from 'vue';
@@ -54,11 +58,15 @@ const bodyFallbackSelector = [
].join(','); ].join(',');
function lockPage() { function lockPage() {
openModalCount += 1;
document.body.classList.add('lock-scroll'); document.body.classList.add('lock-scroll');
} }
function unlockPage() { function unlockPage() {
document.body.classList.remove('lock-scroll'); openModalCount = Math.max(0, openModalCount - 1);
if (openModalCount === 0) {
document.body.classList.remove('lock-scroll');
}
} }
function restoreFocus() { function restoreFocus() {

View File

@@ -48,8 +48,11 @@ export interface GameVersion extends NamedEntity {
export interface Skill extends NamedEntity { export interface Skill extends NamedEntity {
hasItemDrop: boolean; hasItemDrop: boolean;
hasTrading: boolean;
} }
export type TradingPreference = 'like' | 'neutral';
export interface PokemonStats { export interface PokemonStats {
hp: number; hp: number;
attack: number; attack: number;
@@ -174,6 +177,12 @@ export interface Pokemon extends EditInfo {
favorite_things: NamedEntity[]; favorite_things: NamedEntity[];
} }
export interface PokemonTradingItem extends NamedEntity {
itemId: number;
preference: TradingPreference;
image?: EntityImage | null;
}
export interface RelatedPokemon { export interface RelatedPokemon {
id: number; id: number;
displayId: number; displayId: number;
@@ -188,6 +197,7 @@ export interface RelatedPokemon {
export interface PokemonDetail extends Pokemon { export interface PokemonDetail extends Pokemon {
skills: Array<Skill & { itemDrop: (NamedEntity & { image?: EntityImage | null }) | null }>; skills: Array<Skill & { itemDrop: (NamedEntity & { image?: EntityImage | null }) | null }>;
favoriteThingItems: Array<NamedEntity & { image?: EntityImage | null; category: NamedEntity; tags: NamedEntity[] }>; favoriteThingItems: Array<NamedEntity & { image?: EntityImage | null; category: NamedEntity; tags: NamedEntity[] }>;
tradingItems: PokemonTradingItem[];
relatedPokemon: RelatedPokemon[]; relatedPokemon: RelatedPokemon[];
editHistory: EditHistoryEntry[]; editHistory: EditHistoryEntry[];
imageHistory: EntityImageUpload[]; imageHistory: EntityImageUpload[];
@@ -296,6 +306,7 @@ export interface ItemDetail extends Item {
recipe: RecipeDetail | null; recipe: RecipeDetail | null;
relatedRecipes: RecipeUsage[]; relatedRecipes: RecipeUsage[];
relatedHabitats: HabitatUsage[]; relatedHabitats: HabitatUsage[];
possibleTags: ItemPossibleTags;
editHistory: EditHistoryEntry[]; editHistory: EditHistoryEntry[];
imageHistory: EntityImageUpload[]; imageHistory: EntityImageUpload[];
droppedByPokemon: Array<{ droppedByPokemon: Array<{
@@ -304,6 +315,22 @@ export interface ItemDetail extends Item {
}>; }>;
} }
export interface ItemPossibleTags {
highlyLikely: NamedEntity[];
possible: NamedEntity[];
excluded: NamedEntity[];
evidence: {
likes: ItemPossibleTagEvidence[];
neutral: ItemPossibleTagEvidence[];
};
}
export interface ItemPossibleTagEvidence {
pokemon: NamedEntity & { displayId: number; isEventItem: boolean; image?: PokemonImage | null };
preference: TradingPreference;
tags: NamedEntity[];
}
export interface Recipe extends EditInfo { export interface Recipe extends EditInfo {
id: number; id: number;
name: string; name: string;
@@ -761,6 +788,7 @@ export interface PokemonPayload {
skillIds: number[]; skillIds: number[];
favoriteThingIds: number[]; favoriteThingIds: number[];
skillItemDrops: Array<{ skillId: number; itemId: number }>; skillItemDrops: Array<{ skillId: number; itemId: number }>;
tradingItems: Array<{ itemId: number; preference: TradingPreference }>;
imagePath: string; imagePath: string;
} }
@@ -1352,7 +1380,7 @@ export const api = {
config: (type: ConfigType) => getJson<Array<Skill | LifeCategory | GameVersion | NamedEntity>>(`/api/admin/config/${type}`), config: (type: ConfigType) => getJson<Array<Skill | LifeCategory | GameVersion | NamedEntity>>(`/api/admin/config/${type}`),
createConfig: ( createConfig: (
type: ConfigType, type: ConfigType,
payload: { name: string; translations?: TranslationMap; hasItemDrop?: boolean; isDefault?: boolean; isRateable?: boolean; changeLog?: string } payload: { name: string; translations?: TranslationMap; hasItemDrop?: boolean; hasTrading?: boolean; isDefault?: boolean; isRateable?: boolean; changeLog?: string }
) => ) =>
sendJson<Skill | LifeCategory | GameVersion | NamedEntity>(`/api/admin/config/${type}`, 'POST', payload), sendJson<Skill | LifeCategory | GameVersion | NamedEntity>(`/api/admin/config/${type}`, 'POST', payload),
reorderConfig: (type: ConfigType, ids: number[]) => reorderConfig: (type: ConfigType, ids: number[]) =>
@@ -1360,7 +1388,7 @@ export const api = {
updateConfig: ( updateConfig: (
type: ConfigType, type: ConfigType,
id: number, id: number,
payload: { name: string; translations?: TranslationMap; hasItemDrop?: boolean; isDefault?: boolean; isRateable?: boolean; changeLog?: string } payload: { name: string; translations?: TranslationMap; hasItemDrop?: boolean; hasTrading?: boolean; isDefault?: boolean; isRateable?: boolean; changeLog?: string }
) => ) =>
sendJson<Skill | LifeCategory | GameVersion | NamedEntity>(`/api/admin/config/${type}/${id}`, 'PUT', payload), sendJson<Skill | LifeCategory | GameVersion | NamedEntity>(`/api/admin/config/${type}/${id}`, 'PUT', payload),
deleteConfig: (type: ConfigType, id: number) => deleteJson(`/api/admin/config/${type}/${id}`), deleteConfig: (type: ConfigType, id: number) => deleteJson(`/api/admin/config/${type}/${id}`),

View File

@@ -1335,6 +1335,13 @@ svg {
box-shadow: 0 2px 0 var(--line-strong); box-shadow: 0 2px 0 var(--line-strong);
} }
.plain-button--icon {
width: 38px;
min-width: 38px;
height: 38px;
padding: 0;
}
button:disabled, button:disabled,
.ui-button:disabled, .ui-button:disabled,
.primary-button:disabled, .primary-button:disabled,
@@ -5082,6 +5089,227 @@ button:disabled,
justify-content: flex-end; justify-content: flex-end;
} }
.trading-manager__panel,
.trading-selected-group,
.possible-tags-evidence {
display: grid;
gap: 12px;
min-width: 0;
}
.trading-detail-grid,
.possible-tags-grid,
.possible-tags-evidence__grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 12px;
min-width: 0;
}
.trading-detail-group,
.possible-tags-group,
.possible-tags-evidence__group {
min-width: 0;
display: grid;
gap: 9px;
align-content: start;
padding: 12px;
border: 1px solid var(--line);
border-radius: var(--radius-card);
background: var(--surface-soft);
}
.trading-detail-group h3 {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.trading-manager {
min-height: 640px;
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1.1fr);
gap: 16px;
align-items: stretch;
}
.trading-manager__panel {
padding: 12px;
border: 1px solid var(--line);
border-radius: var(--radius-card);
background: var(--surface-soft);
align-content: start;
}
.trading-manager__toolbar {
display: grid;
grid-template-columns: minmax(0, 1fr) 180px;
gap: 12px;
align-items: end;
}
.trading-manager__target {
display: grid;
gap: 8px;
}
.trading-manager__list-frame {
min-height: 420px;
display: grid;
gap: 12px;
}
.trading-manager__list-frame--selected {
align-content: start;
}
.trading-default-toggle {
justify-content: flex-start;
}
.trading-item-list,
.trading-selected-list {
display: grid;
gap: 8px;
margin: 0;
padding: 0;
overflow: auto;
list-style: none;
}
.trading-item-list {
min-height: 360px;
max-height: 420px;
}
.trading-selected-list {
max-height: 220px;
}
.trading-item-list--loading {
align-content: start;
}
.trading-pick-row,
.trading-selected-list li {
width: 100%;
min-width: 0;
display: grid;
align-items: center;
gap: 10px;
padding: 9px;
border: 1px solid var(--line);
border-radius: var(--radius-card);
background: var(--surface);
}
.trading-pick-row {
grid-template-columns: auto minmax(0, 1fr) auto;
color: var(--ink);
text-align: left;
cursor: pointer;
}
.trading-pick-row--selected {
background: var(--surface-soft);
}
.trading-pick-row__copy,
.trading-selected-list__copy {
min-width: 0;
display: grid;
gap: 3px;
}
.trading-pick-row__copy strong,
.trading-selected-list__copy strong,
.possible-tags-evidence__group h4 {
margin: 0;
color: var(--ink);
font-size: 14px;
font-weight: 900;
line-height: 1.2;
overflow-wrap: anywhere;
}
.trading-pick-row__copy span,
.trading-selected-list__copy span {
color: var(--muted);
font-size: 12px;
font-weight: 800;
}
.trading-pick-row__state {
display: inline-flex;
align-items: center;
gap: 5px;
color: var(--pokemon-blue-deep);
font-size: 12px;
font-weight: 950;
}
.trading-selected-list li {
grid-template-columns: auto minmax(0, 1fr) auto auto;
}
.trading-preference-toggle {
justify-content: flex-end;
}
.trading-preference-toggle button {
min-height: 34px;
padding: 7px 9px;
font-size: 12px;
}
.trading-item-list__skeleton {
padding: 9px;
border: 1px solid var(--line);
border-radius: var(--radius-card);
background: var(--surface);
}
.possible-tags-evidence__list li {
align-items: flex-start;
}
.possible-tags-evidence__list .chips {
justify-content: flex-end;
}
@media (max-width: 760px) {
.trading-manager {
grid-template-columns: 1fr;
min-height: 0;
}
.trading-manager__toolbar {
grid-template-columns: 1fr;
}
.trading-selected-list li {
grid-template-columns: auto minmax(0, 1fr);
align-items: start;
}
.trading-manager__list-frame,
.trading-item-list {
min-height: 280px;
max-height: 360px;
}
.trading-selected-list {
max-height: 240px;
}
.trading-preference-toggle,
.trading-selected-list .plain-button--icon {
grid-column: 2;
justify-self: start;
}
}
.pokemon-related-grid { .pokemon-related-grid {
display: grid; display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));

View File

@@ -90,6 +90,7 @@ type AdminNavItem = { key: AdminTab; label: string; permission: string | string[
type AdminNavGroup = { key: AdminGroup; label: string; items: AdminNavItem[] }; type AdminNavGroup = { key: AdminGroup; label: string; items: AdminNavItem[] };
type EditableConfig = (NamedEntity | Skill | LifeCategory | GameVersion) & { type EditableConfig = (NamedEntity | Skill | LifeCategory | GameVersion) & {
hasItemDrop?: boolean; hasItemDrop?: boolean;
hasTrading?: boolean;
isDefault?: boolean; isDefault?: boolean;
isRateable?: boolean; isRateable?: boolean;
changeLog?: string; changeLog?: string;
@@ -194,10 +195,18 @@ const adminNavigationGroups = computed<AdminNavGroup[]>(() => {
const tabs = computed<AdminNavItem[]>(() => adminNavigationGroups.value.flatMap((group) => group.items)); const tabs = computed<AdminNavItem[]>(() => adminNavigationGroups.value.flatMap((group) => group.items));
const configTypes = computed< const configTypes = computed<
Array<{ key: ConfigType; label: string; supportsItemDrop?: boolean; supportsDefault?: boolean; supportsRateable?: boolean; supportsChangeLog?: boolean }> Array<{
key: ConfigType;
label: string;
supportsItemDrop?: boolean;
supportsTrading?: boolean;
supportsDefault?: boolean;
supportsRateable?: boolean;
supportsChangeLog?: boolean;
}>
>(() => [ >(() => [
{ key: 'pokemon-types', label: t('config.pokemonTypes') }, { key: 'pokemon-types', label: t('config.pokemonTypes') },
{ key: 'skills', label: t('config.skills'), supportsItemDrop: true }, { key: 'skills', label: t('config.skills'), supportsItemDrop: true, supportsTrading: true },
{ key: 'environments', label: t('config.environments') }, { key: 'environments', label: t('config.environments') },
{ key: 'favorite-things', label: t('config.favoriteThings') }, { key: 'favorite-things', label: t('config.favoriteThings') },
{ key: 'acquisition-methods', label: t('config.acquisitionMethods') }, { key: 'acquisition-methods', label: t('config.acquisitionMethods') },
@@ -237,6 +246,7 @@ const configForm = ref({
name: '', name: '',
translations: {} as TranslationMap, translations: {} as TranslationMap,
hasItemDrop: false, hasItemDrop: false,
hasTrading: false,
isDefault: false, isDefault: false,
isRateable: false, isRateable: false,
changeLog: '' changeLog: ''
@@ -561,7 +571,7 @@ async function loadLanguages() {
} }
function resetConfigForm() { function resetConfigForm() {
configForm.value = { id: 0, name: '', translations: {}, hasItemDrop: false, isDefault: false, isRateable: false, changeLog: '' }; configForm.value = { id: 0, name: '', translations: {}, hasItemDrop: false, hasTrading: false, isDefault: false, isRateable: false, changeLog: '' };
} }
function resetChecklistForm() { function resetChecklistForm() {
@@ -667,6 +677,7 @@ function editConfig(item: EditableConfig) {
name: item.baseName ?? item.name, name: item.baseName ?? item.name,
translations: item.translations ?? {}, translations: item.translations ?? {},
hasItemDrop: item.hasItemDrop === true, hasItemDrop: item.hasItemDrop === true,
hasTrading: item.hasTrading === true,
isDefault: item.isDefault === true, isDefault: item.isDefault === true,
isRateable: item.isRateable === true, isRateable: item.isRateable === true,
changeLog: item.changeLog ?? '' changeLog: item.changeLog ?? ''
@@ -1047,6 +1058,7 @@ async function saveConfig() {
name: configBaseNameForSave(), name: configBaseNameForSave(),
translations: configForm.value.translations, translations: configForm.value.translations,
hasItemDrop: selectedConfig.value.supportsItemDrop ? configForm.value.hasItemDrop : undefined, hasItemDrop: selectedConfig.value.supportsItemDrop ? configForm.value.hasItemDrop : undefined,
hasTrading: selectedConfig.value.supportsTrading ? configForm.value.hasTrading : undefined,
isDefault: selectedConfig.value.supportsDefault ? configForm.value.isDefault : undefined, isDefault: selectedConfig.value.supportsDefault ? configForm.value.isDefault : undefined,
isRateable: selectedConfig.value.supportsRateable ? configForm.value.isRateable : undefined, isRateable: selectedConfig.value.supportsRateable ? configForm.value.isRateable : undefined,
changeLog: selectedConfig.value.supportsChangeLog ? configForm.value.changeLog : undefined changeLog: selectedConfig.value.supportsChangeLog ? configForm.value.changeLog : undefined
@@ -2002,6 +2014,7 @@ onMounted(() => {
<span class="reorderable-row-title"> <span class="reorderable-row-title">
{{ item.name }} {{ item.name }}
<span v-if="item.hasItemDrop" class="config-flag">{{ t('pages.admin.hasItemDrop') }}</span> <span v-if="item.hasItemDrop" class="config-flag">{{ t('pages.admin.hasItemDrop') }}</span>
<span v-if="item.hasTrading" class="config-flag">{{ t('pages.admin.hasTrading') }}</span>
<span v-if="item.isDefault" class="config-flag">{{ t('pages.admin.defaultCategory') }}</span> <span v-if="item.isDefault" class="config-flag">{{ t('pages.admin.defaultCategory') }}</span>
<span v-if="item.isRateable" class="config-flag">{{ t('pages.admin.rateableCategory') }}</span> <span v-if="item.isRateable" class="config-flag">{{ t('pages.admin.rateableCategory') }}</span>
</span> </span>
@@ -2801,6 +2814,12 @@ onMounted(() => {
{{ t('pages.admin.hasItemDrop') }} {{ t('pages.admin.hasItemDrop') }}
</label> </label>
</div> </div>
<div v-if="selectedConfig.supportsTrading" class="check-row">
<label>
<input v-model="configForm.hasTrading" type="checkbox" />
{{ t('pages.admin.hasTrading') }}
</label>
</div>
<div v-if="selectedConfig.supportsDefault" class="check-row"> <div v-if="selectedConfig.supportsDefault" class="check-row">
<label> <label>
<input v-model="configForm.isDefault" type="checkbox" /> <input v-model="configForm.isDefault" type="checkbox" />

View File

@@ -63,6 +63,15 @@ const basePriceDisplay = computed(() => {
const price = item.value?.basePrice; const price = item.value?.basePrice;
return price === null || price === undefined ? t('common.none') : new Intl.NumberFormat(locale.value).format(price); return price === null || price === undefined ? t('common.none') : new Intl.NumberFormat(locale.value).format(price);
}); });
const possibleTagSections = computed(() => [
{ key: 'highlyLikely', title: t('pages.items.highlyLikelyTags'), tags: item.value?.possibleTags?.highlyLikely ?? [] },
{ key: 'possible', title: t('pages.items.possibleTagsPossible'), tags: item.value?.possibleTags?.possible ?? [] },
{ key: 'excluded', title: t('pages.items.excludedTags'), tags: item.value?.possibleTags?.excluded ?? [] }
]);
const possibleTagEvidenceSections = computed(() => [
{ key: 'likes', title: t('pages.pokemon.tradingLikes'), rows: item.value?.possibleTags?.evidence.likes ?? [] },
{ key: 'neutral', title: t('pages.pokemon.tradingNeutral'), rows: item.value?.possibleTags?.evidence.neutral ?? [] }
]);
const customization = computed(() => { const customization = computed(() => {
if (!item.value) { if (!item.value) {
@@ -269,6 +278,39 @@ watch(
</div> </div>
</div> </div>
<DetailSection :title="t('pages.items.possibleTags')">
<div class="possible-tags-grid">
<div v-for="section in possibleTagSections" :key="section.key" class="possible-tags-group">
<h3 class="section-subtitle">{{ section.title }}</h3>
<EntityChips v-if="section.tags.length" :items="section.tags" />
<p v-else class="meta-line">{{ t('common.none') }}</p>
</div>
</div>
<div class="possible-tags-evidence">
<h3 class="section-subtitle">{{ t('pages.items.possibleTagsEvidence') }}</h3>
<div class="possible-tags-evidence__grid">
<div v-for="section in possibleTagEvidenceSections" :key="section.key" class="possible-tags-evidence__group">
<h4>{{ section.title }}</h4>
<ul v-if="section.rows.length" class="row-list possible-tags-evidence__list">
<li v-for="entry in section.rows" :key="`${section.key}-${entry.pokemon.id}`">
<RouterLink class="related-entity-link related-entity-link--compact" :to="`/pokemon/${entry.pokemon.id}`">
<span class="related-entity-media related-entity-media--inline related-entity-media--pokemon" aria-hidden="true">
<img v-if="entry.pokemon.image" :src="entry.pokemon.image.url" alt="" loading="lazy" />
<PokeBallMark v-else size="22px" />
</span>
<span>#{{ entry.pokemon.displayId }} {{ entry.pokemon.name }}</span>
</RouterLink>
<EntityChips v-if="entry.tags.length" :items="entry.tags" />
<p v-else class="meta-line">{{ t('common.none') }}</p>
</li>
</ul>
<p v-else class="meta-line">{{ t('common.none') }}</p>
</div>
</div>
</div>
</DetailSection>
<div class="detail-grid"> <div class="detail-grid">
<DetailSection :title="t('pages.items.recipeInfo')"> <DetailSection :title="t('pages.items.recipeInfo')">
<template v-if="item.recipe"> <template v-if="item.recipe">

View File

@@ -12,10 +12,11 @@ import PageHeader from '../components/PageHeader.vue';
import PokeBallMark from '../components/PokeBallMark.vue'; import PokeBallMark from '../components/PokeBallMark.vue';
import PokemonStatsPanel from '../components/PokemonStatsPanel.vue'; import PokemonStatsPanel from '../components/PokemonStatsPanel.vue';
import Skeleton from '../components/Skeleton.vue'; import Skeleton from '../components/Skeleton.vue';
import StatusMessage from '../components/StatusMessage.vue';
import Tabs, { type TabOption } from '../components/Tabs.vue'; import Tabs, { type TabOption } from '../components/Tabs.vue';
import { iconBack, iconEdit, iconHabitat, iconItem } from '../icons'; import { iconAdd, iconBack, iconCancel, iconCheck, iconEdit, iconHabitat, iconItem } from '../icons';
import { applySeo } from '../seo'; import { applySeo } from '../seo';
import { api, getAuthToken, type AuthUser, type PokemonDetail } from '../services/api'; import { api, getAuthToken, type AuthUser, type Item, type PokemonDetail, type PokemonPayload, type TradingPreference } from '../services/api';
import PokemonEdit from './PokemonEdit.vue'; import PokemonEdit from './PokemonEdit.vue';
const route = useRoute(); const route = useRoute();
@@ -26,6 +27,15 @@ const itemCategoryTab = ref('');
const relatedHabitatTab = ref(''); const relatedHabitatTab = ref('');
const detailTab = ref('details'); const detailTab = ref('details');
const imageModalOpen = ref(false); const imageModalOpen = ref(false);
const tradingModalOpen = ref(false);
const tradingBusy = ref(false);
const tradingItemsLoading = ref(false);
const tradingMessage = ref('');
const tradingSearch = ref('');
const tradingCategoryId = ref('');
const tradingDefaultPreference = ref<TradingPreference>('like');
const tradingItemChoices = ref<Item[]>([]);
const tradingDraftItems = ref<Array<{ itemId: number; preference: TradingPreference }>>([]);
const timeOfDays = ['早晨', '中午', '傍晚', '晚上']; const timeOfDays = ['早晨', '中午', '傍晚', '晚上'];
const weathers = ['晴天', '阴天', '雨天']; const weathers = ['晴天', '阴天', '雨天'];
const relatedPokemonLimit = 6; const relatedPokemonLimit = 6;
@@ -118,7 +128,60 @@ const habitatRows = computed<HabitatRow[]>(() => {
maps: [...row.maps] maps: [...row.maps]
})); }));
}); });
const skillDropRows = computed(() => pokemon.value?.skills.filter((skill) => skill.itemDrop) ?? []); const skillDropRows = computed(() => pokemon.value?.skills.filter((skill) => skill.hasItemDrop) ?? []);
const hasItemDropSkill = computed(() => skillDropRows.value.length > 0);
const hasTradingSkill = computed(() => pokemon.value?.skills.some((skill) => skill.hasTrading) === true);
const tradingGroups = computed(() => ({
likes: pokemon.value?.tradingItems.filter((item) => item.preference === 'like') ?? [],
neutral: pokemon.value?.tradingItems.filter((item) => item.preference === 'neutral') ?? []
}));
const tradingDetailSections = computed(() => [
{ key: 'like', title: t('pages.pokemon.tradingLikes'), items: tradingGroups.value.likes },
{ key: 'neutral', title: t('pages.pokemon.tradingNeutral'), items: tradingGroups.value.neutral }
]);
const tradingCategoryOptions = computed(() => {
const categories = new Map<string, string>();
tradingItemChoices.value.forEach((item) => {
categories.set(String(item.category.id), item.category.name);
});
return [{ value: '', label: t('common.all') }, ...[...categories.entries()].map(([value, label]) => ({ value, label }))];
});
const tradingDraftPreferenceByItemId = computed(() => new Map(tradingDraftItems.value.map((item) => [String(item.itemId), item.preference])));
const filteredTradingItems = computed(() => {
const search = tradingSearch.value.trim().toLocaleLowerCase();
return tradingItemChoices.value.filter((item) => {
if (tradingCategoryId.value && String(item.category.id) !== tradingCategoryId.value) {
return false;
}
if (!search) {
return true;
}
return [item.name, item.category.name, item.usage?.name ?? ''].some((value) => value.toLocaleLowerCase().includes(search));
});
});
const tradingDraftGroups = computed(() => {
const itemsById = new Map(tradingItemChoices.value.map((item) => [item.id, item]));
const rows = tradingDraftItems.value
.map((item) => {
const row = itemsById.get(item.itemId);
return row ? { ...row, preference: item.preference } : null;
})
.filter((item): item is Item & { preference: TradingPreference } => item !== null);
return {
likes: rows.filter((item) => item.preference === 'like'),
neutral: rows.filter((item) => item.preference === 'neutral')
};
});
const tradingDraftSections = computed(() => [
{ key: 'like' as TradingPreference, title: t('pages.pokemon.tradingLikes'), items: tradingDraftGroups.value.likes },
{ key: 'neutral' as TradingPreference, title: t('pages.pokemon.tradingNeutral'), items: tradingDraftGroups.value.neutral }
]);
const showEditor = computed(() => route.name === 'pokemon-edit'); const showEditor = computed(() => route.name === 'pokemon-edit');
const canUpdatePokemon = computed(() => currentUser.value?.permissions.includes('pokemon.update') === true); const canUpdatePokemon = computed(() => currentUser.value?.permissions.includes('pokemon.update') === true);
const listPath = computed(() => (pokemon.value?.isEventItem ? '/event-pokemon' : '/pokemon')); const listPath = computed(() => (pokemon.value?.isEventItem ? '/event-pokemon' : '/pokemon'));
@@ -220,6 +283,133 @@ function closeImageModal() {
imageModalOpen.value = false; imageModalOpen.value = false;
} }
function buildPokemonPayload(tradingItems: Array<{ itemId: number; preference: TradingPreference }>): PokemonPayload | null {
if (!pokemon.value) {
return null;
}
return {
dataId: pokemon.value.dataId ?? null,
dataIdentifier: pokemon.value.dataIdentifier ?? '',
displayId: pokemon.value.displayId,
isEventItem: pokemon.value.isEventItem,
name: pokemon.value.baseName ?? pokemon.value.name,
genus: pokemon.value.baseGenus ?? pokemon.value.genus,
details: pokemon.value.baseDetails ?? pokemon.value.details,
heightInches: pokemon.value.heightInches,
weightPounds: pokemon.value.weightPounds,
translations: pokemon.value.translations ?? {},
typeIds: pokemon.value.types.map((type) => type.id),
stats: pokemon.value.stats,
environmentId: pokemon.value.environment.id,
skillIds: pokemon.value.skills.map((skill) => skill.id),
favoriteThingIds: pokemon.value.favorite_things.map((thing) => thing.id),
skillItemDrops: pokemon.value.skills
.filter((skill) => skill.hasItemDrop && skill.itemDrop)
.map((skill) => ({ skillId: skill.id, itemId: skill.itemDrop!.id })),
tradingItems: hasTradingSkill.value
? tradingItems.map((item) => ({
itemId: item.itemId,
preference: item.preference
}))
: [],
imagePath: pokemon.value.image?.path ?? ''
};
}
async function loadTradingItems() {
if (tradingItemsLoading.value) {
return;
}
tradingItemsLoading.value = true;
tradingMessage.value = '';
try {
if (!tradingItemChoices.value.length) {
tradingItemChoices.value = await api.items({});
}
} catch (error) {
tradingMessage.value = error instanceof Error && error.message ? error.message : t('errors.loadFailed');
} finally {
tradingItemsLoading.value = false;
}
}
function resetTradingDraft() {
tradingDraftItems.value = pokemon.value?.tradingItems.map((item) => ({
itemId: item.itemId,
preference: item.preference
})) ?? [];
tradingDefaultPreference.value = 'like';
tradingSearch.value = '';
tradingCategoryId.value = '';
tradingMessage.value = '';
}
function isTradingItemSelected(itemId: string | number) {
return tradingDraftPreferenceByItemId.value.has(String(itemId));
}
function addTradingItem(item: Item) {
const itemId = String(item.id);
if (isTradingItemSelected(itemId)) {
return;
}
tradingDraftItems.value.push({ itemId: item.id, preference: tradingDefaultPreference.value });
}
function removeTradingItem(itemId: string | number) {
const value = Number(itemId);
tradingDraftItems.value = tradingDraftItems.value.filter((item) => item.itemId !== value);
}
function setTradingPreference(itemId: string | number, preference: TradingPreference) {
const value = Number(itemId);
const row = tradingDraftItems.value.find((item) => item.itemId === value);
if (row) {
row.preference = preference;
}
}
async function openTradingModal() {
if (!pokemon.value) {
return;
}
resetTradingDraft();
tradingModalOpen.value = true;
await loadTradingItems();
}
function closeTradingModal() {
tradingModalOpen.value = false;
tradingMessage.value = '';
}
async function saveTradingItems() {
if (!pokemon.value || tradingBusy.value) {
return;
}
tradingBusy.value = true;
tradingMessage.value = '';
try {
const payload = buildPokemonPayload(tradingDraftItems.value);
if (!payload) {
return;
}
pokemon.value = await api.updatePokemon(pokemon.value.id, payload);
tradingModalOpen.value = false;
} catch (error) {
tradingMessage.value = error instanceof Error && error.message ? error.message : t('errors.saveFailed');
} finally {
tradingBusy.value = false;
}
}
async function loadPokemonDetail() { async function loadPokemonDetail() {
const nextPokemon = await api.pokemonDetail(String(route.params.id)); const nextPokemon = await api.pokemonDetail(String(route.params.id));
pokemon.value = nextPokemon; pokemon.value = nextPokemon;
@@ -262,6 +452,8 @@ watch(
relatedHabitatTab.value = ''; relatedHabitatTab.value = '';
detailTab.value = 'details'; detailTab.value = 'details';
imageModalOpen.value = false; imageModalOpen.value = false;
tradingModalOpen.value = false;
tradingMessage.value = '';
void loadPokemonDetail(); void loadPokemonDetail();
} }
); );
@@ -404,7 +596,7 @@ watch(
<EntityChips :items="pokemon.skills" /> <EntityChips :items="pokemon.skills" />
</DetailSection> </DetailSection>
<DetailSection v-if="skillDropRows.length" :title="t('pages.pokemon.skillDrops')"> <DetailSection v-if="hasItemDropSkill" :title="t('pages.pokemon.skillDrops')">
<ul class="row-list skill-drop-summary"> <ul class="row-list skill-drop-summary">
<li v-for="skill in skillDropRows" :key="skill.id"> <li v-for="skill in skillDropRows" :key="skill.id">
<span>{{ t('pages.pokemon.skillDrop', { name: skill.name }) }}</span> <span>{{ t('pages.pokemon.skillDrop', { name: skill.name }) }}</span>
@@ -415,10 +607,40 @@ watch(
</span> </span>
<span>{{ skill.itemDrop.name }}</span> <span>{{ skill.itemDrop.name }}</span>
</RouterLink> </RouterLink>
<span v-else class="meta-line">{{ t('common.none') }}</span>
</li> </li>
</ul> </ul>
</DetailSection> </DetailSection>
<DetailSection v-if="hasTradingSkill" :title="t('pages.pokemon.trading')">
<template #actions>
<button v-if="canUpdatePokemon" type="button" class="ui-button ui-button--blue ui-button--small" @click="openTradingModal">
<Icon :icon="iconEdit" class="ui-icon" aria-hidden="true" />
{{ t('pages.pokemon.manageTrading') }}
</button>
</template>
<div class="trading-detail-grid">
<div v-for="section in tradingDetailSections" :key="section.key" class="trading-detail-group">
<h3 class="section-subtitle">
{{ section.title }}
<span v-if="section.key === 'like'" class="chip">{{ t('pages.pokemon.tradingPriceBonus') }}</span>
</h3>
<ul v-if="section.items.length" class="row-list trading-detail-list">
<li v-for="item in section.items" :key="`${section.key}-${item.id}`">
<RouterLink class="related-entity-link related-entity-link--compact" :to="`/items/${item.id}`">
<span class="related-entity-media related-entity-media--inline" aria-hidden="true">
<img v-if="item.image" :src="item.image.url" alt="" loading="lazy" />
<Icon v-else :icon="iconItem" class="related-entity-media__icon" aria-hidden="true" />
</span>
<span>{{ item.name }}</span>
</RouterLink>
</li>
</ul>
<p v-else class="meta-line">{{ t('common.none') }}</p>
</div>
</div>
</DetailSection>
<DetailSection :title="t('pages.pokemon.favoriteThings')"> <DetailSection :title="t('pages.pokemon.favoriteThings')">
<EntityChips :items="pokemon.favorite_things" /> <EntityChips :items="pokemon.favorite_things" />
</DetailSection> </DetailSection>
@@ -567,5 +789,137 @@ watch(
</div> </div>
</Modal> </Modal>
<Modal
v-if="tradingModalOpen"
:title="t('pages.pokemon.trading')"
:subtitle="t('pages.pokemon.tradingModalSubtitle')"
:close-label="t('common.close')"
size="wide"
@close="closeTradingModal"
>
<StatusMessage v-if="tradingMessage" variant="danger">{{ tradingMessage }}</StatusMessage>
<div class="trading-manager">
<section class="trading-manager__panel" :aria-label="t('pages.pokemon.tradingAvailableItems')">
<div class="trading-manager__toolbar">
<div class="field">
<label for="pokemon-trading-search">{{ t('common.search') }}</label>
<input
id="pokemon-trading-search"
v-model="tradingSearch"
type="search"
:placeholder="t('pages.pokemon.searchItems')"
/>
</div>
<div class="field">
<label for="pokemon-trading-category">{{ t('pages.items.category') }}</label>
<select id="pokemon-trading-category" v-model="tradingCategoryId">
<option v-for="option in tradingCategoryOptions" :key="option.value || 'all'" :value="option.value">
{{ option.label }}
</option>
</select>
</div>
</div>
<div class="trading-manager__target">
<span class="field-label">{{ t('pages.pokemon.tradingDefaultGroup') }}</span>
<div class="segmented trading-default-toggle" :aria-label="t('pages.pokemon.tradingDefaultGroup')">
<button :class="{ active: tradingDefaultPreference === 'like' }" type="button" @click="tradingDefaultPreference = 'like'">
{{ t('pages.pokemon.tradingLikes') }}
</button>
<button :class="{ active: tradingDefaultPreference === 'neutral' }" type="button" @click="tradingDefaultPreference = 'neutral'">
{{ t('pages.pokemon.tradingNeutral') }}
</button>
</div>
</div>
<div class="trading-manager__list-frame">
<ul v-if="tradingItemsLoading" class="trading-item-list trading-item-list--loading" aria-busy="true">
<li v-for="index in 6" :key="index" class="trading-item-list__skeleton">
<Skeleton variant="box" height="58px" />
</li>
</ul>
<ul v-else-if="filteredTradingItems.length" class="trading-item-list">
<li v-for="item in filteredTradingItems" :key="item.id">
<button
type="button"
class="trading-pick-row"
:class="{ 'trading-pick-row--selected': isTradingItemSelected(item.id) }"
:disabled="isTradingItemSelected(item.id)"
@click="addTradingItem(item)"
>
<span class="related-entity-media related-entity-media--inline" aria-hidden="true">
<img v-if="item.image" :src="item.image.url" alt="" loading="lazy" />
<Icon v-else :icon="iconItem" class="related-entity-media__icon" aria-hidden="true" />
</span>
<span class="trading-pick-row__copy">
<strong>{{ item.name }}</strong>
<span>{{ item.category.name }}</span>
</span>
<span class="trading-pick-row__state">
<Icon :icon="isTradingItemSelected(item.id) ? iconCheck : iconAdd" class="ui-icon" aria-hidden="true" />
{{ isTradingItemSelected(item.id) ? t('common.selected') : tradingDefaultPreference === 'like' ? t('pages.pokemon.tradingLikes') : t('pages.pokemon.tradingNeutral') }}
</span>
</button>
</li>
</ul>
<p v-else class="meta-line">{{ t('common.noMatches') }}</p>
</div>
</section>
<section class="trading-manager__panel" :aria-label="t('pages.pokemon.tradingSelectedItems')">
<div class="trading-manager__list-frame trading-manager__list-frame--selected">
<div v-for="section in tradingDraftSections" :key="section.key" class="trading-selected-group">
<h3 class="section-subtitle">
{{ section.title }}
<span v-if="section.key === 'like'" class="chip">{{ t('pages.pokemon.tradingPriceBonus') }}</span>
</h3>
<ul v-if="section.items.length" class="trading-selected-list">
<li v-for="item in section.items" :key="`${section.key}-${item.id}`">
<span class="related-entity-media related-entity-media--inline" aria-hidden="true">
<img v-if="item.image" :src="item.image.url" alt="" loading="lazy" />
<Icon v-else :icon="iconItem" class="related-entity-media__icon" aria-hidden="true" />
</span>
<span class="trading-selected-list__copy">
<strong>{{ item.name }}</strong>
<span>{{ item.category.name }}</span>
</span>
<span class="segmented trading-preference-toggle" :aria-label="t('pages.pokemon.tradingPreferenceFor', { name: item.name })">
<button type="button" :class="{ active: item.preference === 'like' }" @click="setTradingPreference(item.id, 'like')">
{{ t('pages.pokemon.tradingLikes') }}
</button>
<button type="button" :class="{ active: item.preference === 'neutral' }" @click="setTradingPreference(item.id, 'neutral')">
{{ t('pages.pokemon.tradingNeutral') }}
</button>
</span>
<button
type="button"
class="plain-button plain-button--icon"
:aria-label="t('common.removeNamed', { name: item.name })"
@click="removeTradingItem(item.id)"
>
<Icon :icon="iconCancel" class="ui-icon" aria-hidden="true" />
</button>
</li>
</ul>
<p v-else class="meta-line">{{ t('common.none') }}</p>
</div>
</div>
</section>
</div>
<template #footer>
<button type="button" class="link-button" :disabled="tradingBusy || tradingItemsLoading" @click="saveTradingItems">
<Icon :icon="iconCheck" class="ui-icon" aria-hidden="true" />
{{ tradingBusy ? t('common.saving') : t('common.save') }}
</button>
<button type="button" class="plain-button" :disabled="tradingBusy || tradingItemsLoading" @click="closeTradingModal">
<Icon :icon="iconCancel" class="ui-icon" aria-hidden="true" />
{{ t('common.cancel') }}
</button>
</template>
</Modal>
<PokemonEdit v-if="showEditor" /> <PokemonEdit v-if="showEditor" />
</template> </template>

View File

@@ -27,6 +27,7 @@ import {
type PokemonImage, type PokemonImage,
type PokemonPayload, type PokemonPayload,
type PokemonStats, type PokemonStats,
type TradingPreference,
type TranslationMap type TranslationMap
} from '../services/api'; } from '../services/api';
@@ -95,6 +96,7 @@ const pokemonForm = ref({
skillIds: [] as string[], skillIds: [] as string[],
favoriteThingIds: [] as string[], favoriteThingIds: [] as string[],
skillItemDrops: [] as SkillItemDropForm[], skillItemDrops: [] as SkillItemDropForm[],
tradingItems: [] as Array<{ itemId: string; preference: TradingPreference }>,
imagePath: '' imagePath: ''
}); });
@@ -145,6 +147,7 @@ const selectedUploadImage = computed(() => (selectedPokemonImage.value?.source =
const canCreateConfig = computed(() => currentUser.value?.permissions.includes('admin.config.create') === true); const canCreateConfig = computed(() => currentUser.value?.permissions.includes('admin.config.create') === true);
const canFetchPokemon = computed(() => currentUser.value?.permissions.includes('pokemon.fetch') === true); const canFetchPokemon = computed(() => currentUser.value?.permissions.includes('pokemon.fetch') === true);
const canUploadImage = computed(() => currentUser.value?.permissions.includes('pokemon.upload') === true); const canUploadImage = computed(() => currentUser.value?.permissions.includes('pokemon.upload') === true);
const hasTradingSkill = computed(() => pokemonForm.value.skillIds.some((skillId) => skillSupportsTrading(skillId)));
function toIds(values: string[]): number[] { function toIds(values: string[]): number[] {
return values.map(Number).filter((item) => Number.isInteger(item) && item > 0); return values.map(Number).filter((item) => Number.isInteger(item) && item > 0);
@@ -225,6 +228,17 @@ function skillSupportsItemDrop(skillId: string) {
return options.value?.skills.some((skill) => String(skill.id) === skillId && skill.hasItemDrop) === true; return options.value?.skills.some((skill) => String(skill.id) === skillId && skill.hasItemDrop) === true;
} }
function skillSupportsTrading(skillId: string) {
return options.value?.skills.some((skill) => String(skill.id) === skillId && skill.hasTrading) === true;
}
function syncSkillFeatures() {
syncSkillItemDrops();
if (!hasTradingSkill.value) {
pokemonForm.value.tradingItems = [];
}
}
function skillDropLabel(skillId: string) { function skillDropLabel(skillId: string) {
const name = skillName(skillId); const name = skillName(skillId);
return name ? t('pages.pokemon.skillDrop', { name }) : t('pages.pokemon.dropItem'); return name ? t('pages.pokemon.skillDrop', { name }) : t('pages.pokemon.dropItem');
@@ -334,12 +348,16 @@ async function loadEditor() {
skillId: String(skill.id), skillId: String(skill.id),
itemId: skill.itemDrop ? String(skill.itemDrop.id) : '' itemId: skill.itemDrop ? String(skill.itemDrop.id) : ''
})), })),
tradingItems: pokemon.tradingItems.map((item) => ({
itemId: String(item.itemId),
preference: item.preference
})),
imagePath: pokemon.image?.path ?? '' imagePath: pokemon.image?.path ?? ''
}; };
currentPokemonImage.value = pokemon.image; currentPokemonImage.value = pokemon.image;
imageOptions.value = pokemon.image ? [pokemon.image] : []; imageOptions.value = pokemon.image ? [pokemon.image] : [];
imageHistory.value = pokemon.imageHistory; imageHistory.value = pokemon.imageHistory;
syncSkillItemDrops(); syncSkillFeatures();
} else { } else {
pokemonForm.value.isEventItem = isEventCreate.value; pokemonForm.value.isEventItem = isEventCreate.value;
} }
@@ -720,6 +738,11 @@ async function savePokemon() {
skillItemDrops: selectedSkillDropRows.value skillItemDrops: selectedSkillDropRows.value
.map((row) => ({ skillId: Number(row.skillId), itemId: Number(row.itemId) })) .map((row) => ({ skillId: Number(row.skillId), itemId: Number(row.itemId) }))
.filter((row) => Number.isInteger(row.skillId) && row.skillId > 0 && Number.isInteger(row.itemId) && row.itemId > 0), .filter((row) => Number.isInteger(row.skillId) && row.skillId > 0 && Number.isInteger(row.itemId) && row.itemId > 0),
tradingItems: hasTradingSkill.value
? pokemonForm.value.tradingItems
.map((item) => ({ itemId: Number(item.itemId), preference: item.preference }))
.filter((item) => Number.isInteger(item.itemId) && item.itemId > 0)
: [],
imagePath: pokemonForm.value.imagePath imagePath: pokemonForm.value.imagePath
}; };
const saved = isEditing.value ? await api.updatePokemon(routeId.value, payload) : await api.createPokemon(payload); const saved = isEditing.value ? await api.updatePokemon(routeId.value, payload) : await api.createPokemon(payload);
@@ -740,7 +763,7 @@ onBeforeUnmount(() => {
removeFetchPositionListeners(); removeFetchPositionListeners();
}); });
watch(() => pokemonForm.value.skillIds.slice(), syncSkillItemDrops); watch(() => pokemonForm.value.skillIds.slice(), syncSkillFeatures);
watch(fetchIdentifier, refreshFetchOptions); watch(fetchIdentifier, refreshFetchOptions);
watch(locale, () => { watch(locale, () => {
resetFetchOptionsCache(); resetFetchOptionsCache();
@@ -749,7 +772,13 @@ watch(locale, () => {
</script> </script>
<template> <template>
<Modal :title="pageTitle" :subtitle="editSubtitle" :close-label="t('common.close')" size="wide" @close="closeEditor"> <Modal
:title="pageTitle"
:subtitle="editSubtitle"
:close-label="t('common.close')"
size="wide"
@close="closeEditor"
>
<StatusMessage v-if="message" variant="danger">{{ message }}</StatusMessage> <StatusMessage v-if="message" variant="danger">{{ message }}</StatusMessage>
<form v-if="!loading && options" id="pokemon-edit-form" class="modal-edit-form modal-edit-form--tabbed pokemon-edit-form" @submit.prevent="savePokemon"> <form v-if="!loading && options" id="pokemon-edit-form" class="modal-edit-form modal-edit-form--tabbed pokemon-edit-form" @submit.prevent="savePokemon">
@@ -1068,4 +1097,5 @@ watch(locale, () => {
</button> </button>
</template> </template>
</Modal> </Modal>
</template> </template>

View File

@@ -619,6 +619,18 @@ export const systemWordingMessages = {
skillDrops: 'Speciality drops', skillDrops: 'Speciality drops',
skillDrop: '{name} drop', skillDrop: '{name} drop',
dropItem: 'Drop item', dropItem: 'Drop item',
trading: 'Trading',
tradingItems: 'Trading items',
tradingLikes: 'Likes',
tradingNeutral: 'Neutral',
tradingPriceBonus: '1.5x price',
tradingSelectedCount: '{count} selected',
tradingModalSubtitle: 'Likes: 1.5x price · Neutral: no bonus',
tradingAvailableItems: 'Available trading items',
tradingSelectedItems: 'Selected trading items',
tradingPreferenceFor: 'Trading preference for {name}',
tradingDefaultGroup: 'Default add target',
manageTrading: 'Manage trading',
searchPokemon: 'Search Pokemon', searchPokemon: 'Search Pokemon',
relatedPokemon: 'Related Pokemon', relatedPokemon: 'Related Pokemon',
relatedHabitat: 'Related Pokemon habitat', relatedHabitat: 'Related Pokemon habitat',
@@ -705,6 +717,11 @@ export const systemWordingMessages = {
relatedRecipes: 'Related recipes', relatedRecipes: 'Related recipes',
relatedHabitats: 'Related habitats', relatedHabitats: 'Related habitats',
pokemonDrops: 'Pokemon drops', pokemonDrops: 'Pokemon drops',
possibleTags: 'Possible Tags',
highlyLikelyTags: 'Highly likely',
possibleTagsPossible: 'Possible',
excludedTags: 'Excluded',
possibleTagsEvidence: 'Evidence',
createRecipe: 'Create recipe', createRecipe: 'Create recipe',
addItem: 'Add item', addItem: 'Add item',
createDefaultsMenu: 'New item options', createDefaultsMenu: 'New item options',
@@ -1053,6 +1070,7 @@ export const systemWordingMessages = {
newConfig: 'New {name}', newConfig: 'New {name}',
editConfig: 'Edit {name}', editConfig: 'Edit {name}',
hasItemDrop: 'Has item drop', hasItemDrop: 'Has item drop',
hasTrading: 'Has trading',
rateableCategory: 'Rateable', rateableCategory: 'Rateable',
changeLog: 'ChangeLog', changeLog: 'ChangeLog',
dragSort: 'Drag to reorder: {name}', dragSort: 'Drag to reorder: {name}',
@@ -1964,6 +1982,18 @@ export const systemWordingMessages = {
skillDrops: '特长掉落物', skillDrops: '特长掉落物',
skillDrop: '{name}掉落物', skillDrop: '{name}掉落物',
dropItem: '掉落物', dropItem: '掉落物',
trading: 'Trading',
tradingItems: 'Trading 物品',
tradingLikes: 'Likes',
tradingNeutral: 'Neutral',
tradingPriceBonus: '1.5x 价格',
tradingSelectedCount: '已选择 {count} 个',
tradingModalSubtitle: 'Likes1.5x 价格 · Neutral无加成',
tradingAvailableItems: '可选 Trading 物品',
tradingSelectedItems: '已选 Trading 物品',
tradingPreferenceFor: '{name} 的 Trading 偏好',
tradingDefaultGroup: '默认加入组',
manageTrading: '管理 Trading',
searchPokemon: '搜索 Pokemon', searchPokemon: '搜索 Pokemon',
relatedPokemon: '相关 Pokemon', relatedPokemon: '相关 Pokemon',
relatedHabitat: '相关 Pokemon 栖息地', relatedHabitat: '相关 Pokemon 栖息地',
@@ -2050,6 +2080,11 @@ export const systemWordingMessages = {
relatedRecipes: '相关材料单', relatedRecipes: '相关材料单',
relatedHabitats: '相关栖息地', relatedHabitats: '相关栖息地',
pokemonDrops: 'Pokemon 掉落', pokemonDrops: 'Pokemon 掉落',
possibleTags: 'Possible Tags',
highlyLikelyTags: '高度可能',
possibleTagsPossible: '可能',
excludedTags: '已排除',
possibleTagsEvidence: '证据',
createRecipe: '创建材料单', createRecipe: '创建材料单',
addItem: '新增物品', addItem: '新增物品',
createDefaultsMenu: '新增物品选项', createDefaultsMenu: '新增物品选项',
@@ -2398,6 +2433,7 @@ export const systemWordingMessages = {
newConfig: '新增{name}', newConfig: '新增{name}',
editConfig: '编辑{name}', editConfig: '编辑{name}',
hasItemDrop: '有掉落物', hasItemDrop: '有掉落物',
hasTrading: '有 Trading',
rateableCategory: '可评分', rateableCategory: '可评分',
changeLog: 'ChangeLog', changeLog: 'ChangeLog',
dragSort: '拖曳排序:{name}', dragSort: '拖曳排序:{name}',