feat(wiki): add event item flag and decouple pokemon display ID

Add `is_event_item` to pokemon, items, and habitats.
Separate internal `id` and `display_id` for pokemon to allow event variants.
Update frontend forms and views to support the new fields.
This commit is contained in:
2026-05-03 10:11:04 +08:00
parent 4d05618530
commit 3d99f00c75
13 changed files with 191 additions and 58 deletions

View File

@@ -246,7 +246,9 @@
Pokemon 可配置: Pokemon 可配置:
- ID - 内部 ID`id`,系统唯一,用于路由、外键和实体关联;普通 Pokemon 新建时优先与展示 ID 一致,活动 Pokemon 由系统分配唯一内部 ID
- 展示 ID`display_id`,详情页、列表卡片和选择器中显示为 `#ID`
- 是否为活动物品:`is_event_item`
- 名称 - 名称
- Genus可为空支持翻译 - Genus可为空支持翻译
- 介绍 / Details可为空支持翻译 - 介绍 / Details可为空支持翻译
@@ -269,6 +271,8 @@ Pokemon 可配置:
- 翻译 - 翻译
- 排序 - 排序
Pokemon 的展示 ID 在普通 Pokemon 和活动 Pokemon 之间可以重复,例如允许同时存在普通 `#1 妙蛙种子` 和活动 `#1 毽子草`。数据库只要求同一个 `display_id + is_event_item` 组合唯一;前端路由和实体关联必须继续使用内部 `id`,不能使用展示 ID 作为路由或外键。
Pokemon 编辑表单使用标签页组织字段: Pokemon 编辑表单使用标签页组织字段:
- 编辑表单提供 Fetch data 功能: - 编辑表单提供 Fetch data 功能:
@@ -341,6 +345,7 @@ Pokemon 详情页展示:
物品可配置: 物品可配置:
- 名称 - 名称
- 是否为活动物品:`is_event_item`
- 分类:必填 - 分类:必填
- 用途:可为空 - 用途:可为空
- 入手方式:可多选 - 入手方式:可多选
@@ -423,6 +428,7 @@ Pokemon 详情页展示:
栖息地可配置: 栖息地可配置:
- 名称 - 名称
- 是否为活动物品:`is_event_item`
- 配方:多项物品 + 数量 - 配方:多项物品 + 数量
- 可出现的 Pokemon - 可出现的 Pokemon
- 图片:通过通用 Wiki 图片上传维护当前图片和历史上传记录 - 图片:通过通用 Wiki 图片上传维护当前图片和历史上传记录

View File

@@ -263,7 +263,9 @@ CREATE TABLE IF NOT EXISTS pokemon_types (
CREATE TABLE IF NOT EXISTS pokemon ( CREATE TABLE IF NOT EXISTS pokemon (
id integer PRIMARY KEY, id integer PRIMARY KEY,
display_id integer NOT NULL CHECK (display_id > 0),
name text NOT NULL UNIQUE, name text NOT NULL UNIQUE,
is_event_item boolean NOT NULL DEFAULT false,
genus text NOT NULL DEFAULT '', genus text NOT NULL DEFAULT '',
details text NOT NULL DEFAULT '', details text NOT NULL DEFAULT '',
height_inches double precision NOT NULL DEFAULT 0 CHECK (height_inches >= 0), height_inches double precision NOT NULL DEFAULT 0 CHECK (height_inches >= 0),
@@ -330,12 +332,14 @@ CREATE TABLE IF NOT EXISTS items (
dual_dyeable boolean NOT NULL DEFAULT false, dual_dyeable boolean NOT NULL DEFAULT false,
pattern_editable boolean NOT NULL DEFAULT false, pattern_editable boolean NOT NULL DEFAULT false,
no_recipe boolean NOT NULL DEFAULT false, no_recipe boolean NOT NULL DEFAULT false,
is_event_item boolean NOT NULL DEFAULT false,
image_path text NOT NULL DEFAULT '', image_path text NOT NULL DEFAULT '',
sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0) sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0)
); );
ALTER TABLE items ALTER COLUMN usage_id DROP NOT NULL; ALTER TABLE items ALTER COLUMN usage_id DROP NOT NULL;
ALTER TABLE items ADD COLUMN IF NOT EXISTS no_recipe boolean NOT NULL DEFAULT false; ALTER TABLE items ADD COLUMN IF NOT EXISTS no_recipe boolean NOT NULL DEFAULT false;
ALTER TABLE items ADD COLUMN IF NOT EXISTS is_event_item boolean NOT NULL DEFAULT false;
ALTER TABLE items ADD COLUMN IF NOT EXISTS image_path text NOT NULL DEFAULT ''; ALTER TABLE items ADD COLUMN IF NOT EXISTS image_path text NOT NULL DEFAULT '';
ALTER TABLE items DROP COLUMN IF EXISTS no_habitat; ALTER TABLE items DROP COLUMN IF EXISTS no_habitat;
@@ -422,6 +426,7 @@ CREATE TABLE IF NOT EXISTS maps (
CREATE TABLE IF NOT EXISTS habitats ( CREATE TABLE IF NOT EXISTS habitats (
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,
is_event_item boolean NOT NULL DEFAULT false,
image_path text NOT NULL DEFAULT '', image_path text NOT NULL DEFAULT '',
sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0) sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0)
); );
@@ -472,6 +477,10 @@ ALTER TABLE pokemon ADD COLUMN IF NOT EXISTS updated_by_user_id integer REFERENC
ALTER TABLE pokemon ADD COLUMN IF NOT EXISTS created_at timestamptz NOT NULL DEFAULT now(); ALTER TABLE pokemon ADD COLUMN IF NOT EXISTS created_at timestamptz NOT NULL DEFAULT now();
ALTER TABLE pokemon ADD COLUMN IF NOT EXISTS updated_at timestamptz NOT NULL DEFAULT now(); ALTER TABLE pokemon ADD COLUMN IF NOT EXISTS updated_at timestamptz NOT NULL DEFAULT now();
ALTER TABLE pokemon ADD COLUMN IF NOT EXISTS sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0); ALTER TABLE pokemon ADD COLUMN IF NOT EXISTS sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0);
ALTER TABLE pokemon ADD COLUMN IF NOT EXISTS display_id integer;
UPDATE pokemon SET display_id = id WHERE display_id IS NULL;
ALTER TABLE pokemon ALTER COLUMN display_id SET NOT NULL;
ALTER TABLE pokemon ADD COLUMN IF NOT EXISTS is_event_item boolean NOT NULL DEFAULT false;
ALTER TABLE pokemon ADD COLUMN IF NOT EXISTS genus text NOT NULL DEFAULT ''; ALTER TABLE pokemon ADD COLUMN IF NOT EXISTS genus text NOT NULL DEFAULT '';
ALTER TABLE pokemon ADD COLUMN IF NOT EXISTS details text NOT NULL DEFAULT ''; ALTER TABLE pokemon ADD COLUMN IF NOT EXISTS details text NOT NULL DEFAULT '';
ALTER TABLE pokemon ADD COLUMN IF NOT EXISTS height_inches double precision NOT NULL DEFAULT 0 CHECK (height_inches >= 0); ALTER TABLE pokemon ADD COLUMN IF NOT EXISTS height_inches double precision NOT NULL DEFAULT 0 CHECK (height_inches >= 0);
@@ -517,6 +526,7 @@ ALTER TABLE items ADD COLUMN IF NOT EXISTS updated_by_user_id integer REFERENCES
ALTER TABLE items ADD COLUMN IF NOT EXISTS created_at timestamptz NOT NULL DEFAULT now(); ALTER TABLE items ADD COLUMN IF NOT EXISTS created_at timestamptz NOT NULL DEFAULT now();
ALTER TABLE items ADD COLUMN IF NOT EXISTS updated_at timestamptz NOT NULL DEFAULT now(); ALTER TABLE items ADD COLUMN IF NOT EXISTS updated_at timestamptz NOT NULL DEFAULT now();
ALTER TABLE items ADD COLUMN IF NOT EXISTS sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0); ALTER TABLE items ADD COLUMN IF NOT EXISTS sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0);
ALTER TABLE items ADD COLUMN IF NOT EXISTS is_event_item boolean NOT NULL DEFAULT false;
ALTER TABLE recipes ADD COLUMN IF NOT EXISTS created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL; ALTER TABLE recipes ADD COLUMN IF NOT EXISTS created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL;
ALTER TABLE recipes ADD COLUMN IF NOT EXISTS updated_by_user_id integer REFERENCES users(id) ON DELETE SET NULL; ALTER TABLE recipes ADD COLUMN IF NOT EXISTS updated_by_user_id integer REFERENCES users(id) ON DELETE SET NULL;
@@ -535,6 +545,7 @@ ALTER TABLE habitats ADD COLUMN IF NOT EXISTS updated_by_user_id integer REFEREN
ALTER TABLE habitats ADD COLUMN IF NOT EXISTS created_at timestamptz NOT NULL DEFAULT now(); ALTER TABLE habitats ADD COLUMN IF NOT EXISTS created_at timestamptz NOT NULL DEFAULT now();
ALTER TABLE habitats ADD COLUMN IF NOT EXISTS updated_at timestamptz NOT NULL DEFAULT now(); ALTER TABLE habitats ADD COLUMN IF NOT EXISTS updated_at timestamptz NOT NULL DEFAULT now();
ALTER TABLE habitats ADD COLUMN IF NOT EXISTS sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0); ALTER TABLE habitats ADD COLUMN IF NOT EXISTS sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0);
ALTER TABLE habitats ADD COLUMN IF NOT EXISTS is_event_item boolean NOT NULL DEFAULT false;
ALTER TABLE habitats ADD COLUMN IF NOT EXISTS image_path text NOT NULL DEFAULT ''; ALTER TABLE habitats ADD COLUMN IF NOT EXISTS image_path text NOT NULL DEFAULT '';
WITH ordered AS ( WITH ordered AS (
@@ -672,6 +683,7 @@ CREATE INDEX IF NOT EXISTS skills_sort_order_idx ON skills(sort_order, id);
CREATE INDEX IF NOT EXISTS favorite_things_sort_order_idx ON favorite_things(sort_order, id); CREATE INDEX IF NOT EXISTS favorite_things_sort_order_idx ON favorite_things(sort_order, id);
CREATE INDEX IF NOT EXISTS pokemon_types_sort_order_idx ON pokemon_types(sort_order, id); CREATE INDEX IF NOT EXISTS pokemon_types_sort_order_idx ON pokemon_types(sort_order, id);
CREATE INDEX IF NOT EXISTS pokemon_sort_order_idx ON pokemon(sort_order, id); CREATE INDEX IF NOT EXISTS pokemon_sort_order_idx ON pokemon(sort_order, id);
CREATE UNIQUE INDEX IF NOT EXISTS pokemon_display_event_item_key ON pokemon(display_id, is_event_item);
CREATE INDEX IF NOT EXISTS life_tags_sort_order_idx ON life_tags(sort_order, id); CREATE INDEX IF NOT EXISTS life_tags_sort_order_idx ON life_tags(sort_order, id);
CREATE INDEX IF NOT EXISTS item_categories_sort_order_idx ON item_categories(sort_order, id); CREATE INDEX IF NOT EXISTS item_categories_sort_order_idx ON item_categories(sort_order, id);
CREATE INDEX IF NOT EXISTS item_usages_sort_order_idx ON item_usages(sort_order, id); CREATE INDEX IF NOT EXISTS item_usages_sort_order_idx ON item_usages(sort_order, id);

View File

@@ -101,7 +101,8 @@ type PokemonImageOptionsResult = {
}; };
type PokemonPayload = { type PokemonPayload = {
id: number; displayId: number;
isEventItem: boolean;
name: string; name: string;
genus: string; genus: string;
details: string; details: string;
@@ -154,6 +155,7 @@ type ItemPayload = {
dualDyeable: boolean; dualDyeable: boolean;
patternEditable: boolean; patternEditable: boolean;
noRecipe: boolean; noRecipe: boolean;
isEventItem: boolean;
acquisitionMethodIds: number[]; acquisitionMethodIds: number[];
tagIds: number[]; tagIds: number[];
imagePath: string; imagePath: string;
@@ -250,6 +252,7 @@ type LifePostsPage = {
type HabitatPayload = { type HabitatPayload = {
name: string; name: string;
translations: TranslationInput; translations: TranslationInput;
isEventItem: boolean;
imagePath: string; imagePath: string;
recipeItems: IdQuantity[]; recipeItems: IdQuantity[];
pokemonAppearances: Array<{ pokemonAppearances: Array<{
@@ -283,6 +286,8 @@ type EditHistoryEntry = {
user: { id: number; displayName: string } | null; user: { id: number; displayName: string } | null;
}; };
type PokemonChangeSource = { type PokemonChangeSource = {
displayId: number;
isEventItem: boolean;
name: string; name: string;
genus: string; genus: string;
details: string; details: string;
@@ -297,6 +302,7 @@ type PokemonChangeSource = {
}; };
type ItemChangeSource = { type ItemChangeSource = {
name: string; name: string;
isEventItem: boolean;
image: EntityImageValue | null; image: EntityImageValue | null;
category: { name: string }; category: { name: string };
usage: { name: string } | null; usage: { name: string } | null;
@@ -307,6 +313,7 @@ type ItemChangeSource = {
}; };
type HabitatChangeSource = { type HabitatChangeSource = {
name: string; name: string;
isEventItem: boolean;
image: EntityImageValue | null; image: EntityImageValue | null;
recipe: Array<{ name: string; quantity: number }>; recipe: Array<{ name: string; quantity: number }>;
pokemon: Array<{ name: string; time_of_day: string; weather: string; rarity: number; map: { name: string } }>; pokemon: Array<{ name: string; time_of_day: string; weather: string; rarity: number; map: { name: string } }>;
@@ -712,6 +719,30 @@ async function nextSortOrder(client: DbClient, tableName: string): Promise<numbe
return result.rows[0]?.sortOrder ?? 10; return result.rows[0]?.sortOrder ?? 10;
} }
async function nextPokemonInternalId(client: DbClient, displayId: number, isEventItem: boolean): Promise<number> {
if (isEventItem) {
const result = await client.query<{ id: number }>(
'SELECT COALESCE(MAX(id), 999999) + 1 AS id FROM pokemon WHERE id >= 1000000'
);
const nextId = result.rows[0]?.id ?? 1000000;
return nextId === displayId ? nextId + 1 : nextId;
}
if (!isEventItem) {
const preferredId = await client.query<{ id: number }>('SELECT id FROM pokemon WHERE id = $1', [displayId]);
if (preferredId.rowCount === 0) {
return displayId;
}
}
const result = await client.query<{ id: number }>(
'SELECT COALESCE(MAX(id), 0) + 1 AS id FROM pokemon WHERE id <> $1',
[displayId]
);
const nextId = result.rows[0]?.id ?? 1;
return nextId === displayId ? nextId + 1 : nextId;
}
async function reorderTableRows( async function reorderTableRows(
client: DbClient, client: DbClient,
tableName: string, tableName: string,
@@ -1717,6 +1748,8 @@ async function pokemonEditChanges(
.join(' / '); .join(' / ');
pushChange(changes, 'Name', before.name, after.name); pushChange(changes, 'Name', before.name, after.name);
pushChange(changes, 'Pokemon ID', String(before.displayId), String(after.displayId));
pushChange(changes, 'Event item', boolValue(before.isEventItem), boolValue(after.isEventItem));
pushChange(changes, 'Genus', before.genus, after.genus); pushChange(changes, 'Genus', before.genus, after.genus);
pushChange(changes, 'Details', before.details, after.details); pushChange(changes, 'Details', before.details, after.details);
pushChange(changes, 'Height', pokemonHeightValue(before.heightInches), pokemonHeightValue(after.heightInches)); pushChange(changes, 'Height', pokemonHeightValue(before.heightInches), pokemonHeightValue(after.heightInches));
@@ -1744,6 +1777,7 @@ async function itemEditChanges(
const tagNames = await entityNameMap(client, 'favorite_things', after.tagIds); const tagNames = await entityNameMap(client, 'favorite_things', after.tagIds);
pushChange(changes, 'Name', before.name, after.name); pushChange(changes, 'Name', before.name, after.name);
pushChange(changes, 'Event item', boolValue(before.isEventItem), boolValue(after.isEventItem));
pushChange(changes, 'Image', imagePathLabel(before.image?.path), imagePathLabel(after.imagePath)); pushChange(changes, 'Image', imagePathLabel(before.image?.path), imagePathLabel(after.imagePath));
pushChange(changes, 'Category', before.category.name, categoryNames.get(after.categoryId)); pushChange(changes, 'Category', before.category.name, categoryNames.get(after.categoryId));
pushChange(changes, 'Usage', before.usage?.name, after.usageId ? usageNames.get(after.usageId) : null); pushChange(changes, 'Usage', before.usage?.name, after.usageId ? usageNames.get(after.usageId) : null);
@@ -1776,6 +1810,7 @@ async function habitatEditChanges(
.join(' / '); .join(' / ');
pushChange(changes, 'Name', before.name, after.name); pushChange(changes, 'Name', before.name, after.name);
pushChange(changes, 'Event item', boolValue(before.isEventItem), boolValue(after.isEventItem));
pushChange(changes, 'Image', imagePathLabel(before.image?.path), imagePathLabel(after.imagePath)); pushChange(changes, 'Image', imagePathLabel(before.image?.path), imagePathLabel(after.imagePath));
pushChange(changes, 'Recipe', quantityListValue(before.recipe), await quantityPayloadValue(client, after.recipeItems)); pushChange(changes, 'Recipe', quantityListValue(before.recipe), await quantityPayloadValue(client, after.recipeItems));
pushChange(changes, 'Possible Pokemon', appearanceListValue(before.pokemon), afterAppearances); pushChange(changes, 'Possible Pokemon', appearanceListValue(before.pokemon), afterAppearances);
@@ -1832,8 +1867,10 @@ function pokemonProjection(locale: string): string {
return ` return `
SELECT SELECT
p.id, p.id,
p.display_id AS "displayId",
${pokemonName} AS name, ${pokemonName} AS name,
p.name AS "baseName", p.name AS "baseName",
p.is_event_item AS "isEventItem",
${pokemonGenus} AS genus, ${pokemonGenus} AS genus,
p.genus AS "baseGenus", p.genus AS "baseGenus",
${pokemonDetails} AS details, ${pokemonDetails} AS details,
@@ -3117,7 +3154,9 @@ export async function getPokemon(id: number, locale = defaultLocale) {
) )
SELECT SELECT
related_pokemon.id, related_pokemon.id,
related_pokemon.display_id AS "displayId",
${relatedPokemonName} AS name, ${relatedPokemonName} AS name,
related_pokemon.is_event_item AS "isEventItem",
${pokemonImageJson('related_pokemon')} AS image, ${pokemonImageJson('related_pokemon')} AS image,
json_build_object('id', related_environment.id, 'name', ${relatedEnvironmentName}) AS environment, json_build_object('id', related_environment.id, 'name', ${relatedEnvironmentName}) AS environment,
COALESCE(( COALESCE((
@@ -3215,10 +3254,11 @@ function cleanPokemonPayload(payload: Record<string, unknown>): PokemonPayload {
} }
} }
const id = requirePositiveInteger(payload.id, 'Pokemon ID is required'); const displayId = requirePositiveInteger(payload.displayId ?? payload.id, 'Pokemon ID is required');
return { return {
id, displayId,
isEventItem: Boolean(payload.isEventItem),
name: cleanName(payload.name, 'Pokemon name is required'), name: cleanName(payload.name, 'Pokemon name is required'),
genus: cleanOptionalText(payload.genus), genus: cleanOptionalText(payload.genus),
details: cleanOptionalText(payload.details), details: cleanOptionalText(payload.details),
@@ -3231,7 +3271,7 @@ function cleanPokemonPayload(payload: Record<string, unknown>): PokemonPayload {
skillIds, skillIds,
favoriteThingIds, favoriteThingIds,
skillItemDrops: [...skillItemDrops.values()], skillItemDrops: [...skillItemDrops.values()],
image: cleanPokemonImage(payload.imagePath, id) image: cleanPokemonImage(payload.imagePath, displayId)
}; };
} }
@@ -3284,12 +3324,15 @@ export async function createPokemon(payload: Record<string, unknown>, userId: nu
const cleanPayload = cleanPokemonPayload(payload); const cleanPayload = cleanPokemonPayload(payload);
const id = await withTransaction(async (client) => { const id = await withTransaction(async (client) => {
const pokemonId = await nextPokemonInternalId(client, cleanPayload.displayId, cleanPayload.isEventItem);
const sortOrder = await nextSortOrder(client, 'pokemon'); const sortOrder = await nextSortOrder(client, 'pokemon');
await client.query( await client.query(
` `
INSERT INTO pokemon ( INSERT INTO pokemon (
id, id,
display_id,
name, name,
is_event_item,
genus, genus,
details, details,
height_inches, height_inches,
@@ -3310,11 +3353,13 @@ export async function createPokemon(payload: Record<string, unknown>, userId: nu
created_by_user_id, created_by_user_id,
updated_by_user_id updated_by_user_id
) )
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $20) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $22)
`, `,
[ [
cleanPayload.id, pokemonId,
cleanPayload.displayId,
cleanPayload.name, cleanPayload.name,
cleanPayload.isEventItem,
cleanPayload.genus, cleanPayload.genus,
cleanPayload.details, cleanPayload.details,
cleanPayload.heightInches, cleanPayload.heightInches,
@@ -3335,17 +3380,17 @@ export async function createPokemon(payload: Record<string, unknown>, userId: nu
userId userId
] ]
); );
await linkEntityImageUpload(client, 'pokemon', cleanPayload.id, cleanPayload.image?.path, cleanPayload.name); await linkEntityImageUpload(client, 'pokemon', pokemonId, cleanPayload.image?.path, cleanPayload.name);
await replacePokemonRelations(client, cleanPayload.id, cleanPayload); await replacePokemonRelations(client, pokemonId, cleanPayload);
await replaceEntityTranslations(client, 'pokemon', cleanPayload.id, cleanPayload.translations, ['name', 'details', 'genus']); await replaceEntityTranslations(client, 'pokemon', pokemonId, cleanPayload.translations, ['name', 'details', 'genus']);
await recordEditLog(client, 'pokemon', cleanPayload.id, 'create', userId); await recordEditLog(client, 'pokemon', pokemonId, 'create', userId);
return cleanPayload.id; return pokemonId;
}); });
return getPokemon(id, locale); return getPokemon(id, locale);
} }
export async function updatePokemon(id: number, payload: Record<string, unknown>, userId: number, locale = defaultLocale) { export async function updatePokemon(id: number, payload: Record<string, unknown>, userId: number, locale = defaultLocale) {
const cleanPayload = cleanPokemonPayload({ ...payload, id }); const cleanPayload = cleanPokemonPayload(payload);
const before = await getPokemon(id, defaultLocale); const before = await getPokemon(id, defaultLocale);
const updated = await withTransaction(async (client) => { const updated = await withTransaction(async (client) => {
@@ -3353,29 +3398,33 @@ export async function updatePokemon(id: number, payload: Record<string, unknown>
` `
UPDATE pokemon UPDATE pokemon
SET SET
name = $1, display_id = $1,
genus = $2, name = $2,
details = $3, is_event_item = $3,
height_inches = $4, genus = $4,
weight_pounds = $5, details = $5,
environment_id = $6, height_inches = $6,
hp = $7, weight_pounds = $7,
attack = $8, environment_id = $8,
defense = $9, hp = $9,
special_attack = $10, attack = $10,
special_defense = $11, defense = $11,
speed = $12, special_attack = $12,
image_path = $13, special_defense = $13,
image_style = $14, speed = $14,
image_version = $15, image_path = $15,
image_variant = $16, image_style = $16,
image_description = $17, image_version = $17,
updated_by_user_id = $18, image_variant = $18,
image_description = $19,
updated_by_user_id = $20,
updated_at = now() updated_at = now()
WHERE id = $19 WHERE id = $21
`, `,
[ [
cleanPayload.displayId,
cleanPayload.name, cleanPayload.name,
cleanPayload.isEventItem,
cleanPayload.genus, cleanPayload.genus,
cleanPayload.details, cleanPayload.details,
cleanPayload.heightInches, cleanPayload.heightInches,
@@ -3433,6 +3482,7 @@ export async function listHabitats(locale = defaultLocale) {
h.id, h.id,
${habitatName} AS name, ${habitatName} AS name,
h.name AS "baseName", h.name AS "baseName",
h.is_event_item AS "isEventItem",
${translationsSelect('habitats', 'h.id')} AS translations, ${translationsSelect('habitats', 'h.id')} AS translations,
${auditSelect('h', 'habitat_created_user', 'habitat_updated_user')}, ${auditSelect('h', 'habitat_created_user', 'habitat_updated_user')},
${uploadedImageJson('h.image_path')} AS image, ${uploadedImageJson('h.image_path')} AS image,
@@ -3443,9 +3493,17 @@ export async function listHabitats(locale = defaultLocale) {
WHERE hri.habitat_id = h.id WHERE hri.habitat_id = h.id
), '[]'::json) AS recipe, ), '[]'::json) AS recipe,
COALESCE(( COALESCE((
SELECT json_agg(json_build_object('id', pokemon_rows.id, 'name', pokemon_rows.name) ORDER BY pokemon_rows.sort_order, pokemon_rows.id) SELECT json_agg(
json_build_object(
'id', pokemon_rows.id,
'displayId', pokemon_rows.display_id,
'name', pokemon_rows.name,
'isEventItem', pokemon_rows.is_event_item
)
ORDER BY pokemon_rows.sort_order, pokemon_rows.id
)
FROM ( FROM (
SELECT DISTINCT p.id, ${pokemonName} AS name, p.sort_order SELECT DISTINCT p.id, p.display_id, ${pokemonName} AS name, p.is_event_item, p.sort_order
FROM habitat_pokemon hp FROM habitat_pokemon hp
JOIN pokemon p ON p.id = hp.pokemon_id JOIN pokemon p ON p.id = hp.pokemon_id
WHERE hp.habitat_id = h.id WHERE hp.habitat_id = h.id
@@ -3469,6 +3527,7 @@ export async function getHabitat(id: number, locale = defaultLocale) {
h.id, h.id,
${habitatName} AS name, ${habitatName} AS name,
h.name AS "baseName", h.name AS "baseName",
h.is_event_item AS "isEventItem",
${translationsSelect('habitats', 'h.id')} AS translations, ${translationsSelect('habitats', 'h.id')} AS translations,
${auditSelect('h', 'habitat_created_user', 'habitat_updated_user')}, ${auditSelect('h', 'habitat_created_user', 'habitat_updated_user')},
${uploadedImageJson('h.image_path')} AS image, ${uploadedImageJson('h.image_path')} AS image,
@@ -3502,7 +3561,9 @@ export async function getHabitat(id: number, locale = defaultLocale) {
` `
SELECT SELECT
p.id, p.id,
p.display_id AS "displayId",
${pokemonName} AS name, ${pokemonName} AS name,
p.is_event_item AS "isEventItem",
${pokemonImageJson('p')} AS image, ${pokemonImageJson('p')} AS image,
hp.time_of_day, hp.time_of_day,
hp.weather, hp.weather,
@@ -3557,6 +3618,7 @@ function cleanHabitatPayload(payload: Record<string, unknown>): HabitatPayload {
return { return {
name: cleanName(payload.name, 'Habitat name is required'), name: cleanName(payload.name, 'Habitat name is required'),
translations: cleanTranslations(payload.translations, ['name']), translations: cleanTranslations(payload.translations, ['name']),
isEventItem: Boolean(payload.isEventItem),
imagePath: cleanUploadImagePath(payload.imagePath, 'habitats'), imagePath: cleanUploadImagePath(payload.imagePath, 'habitats'),
recipeItems: cleanQuantities(payload.recipeItems), recipeItems: cleanQuantities(payload.recipeItems),
pokemonAppearances: [...pokemonAppearances.values()] pokemonAppearances: [...pokemonAppearances.values()]
@@ -3593,11 +3655,11 @@ export async function createHabitat(payload: Record<string, unknown>, userId: nu
const sortOrder = await nextSortOrder(client, 'habitats'); const sortOrder = await nextSortOrder(client, 'habitats');
const result = await client.query<{ id: number }>( const result = await client.query<{ id: number }>(
` `
INSERT INTO habitats (name, image_path, sort_order, created_by_user_id, updated_by_user_id) INSERT INTO habitats (name, is_event_item, image_path, sort_order, created_by_user_id, updated_by_user_id)
VALUES ($1, $2, $3, $4, $4) VALUES ($1, $2, $3, $4, $5, $5)
RETURNING id RETURNING id
`, `,
[cleanPayload.name, cleanPayload.imagePath, sortOrder, userId] [cleanPayload.name, cleanPayload.isEventItem, cleanPayload.imagePath, sortOrder, userId]
); );
const habitatId = result.rows[0].id; const habitatId = result.rows[0].id;
await linkEntityImageUpload(client, 'habitats', habitatId, cleanPayload.imagePath, cleanPayload.name); await linkEntityImageUpload(client, 'habitats', habitatId, cleanPayload.imagePath, cleanPayload.name);
@@ -3615,8 +3677,8 @@ export async function updateHabitat(id: number, payload: Record<string, unknown>
const updated = await withTransaction(async (client) => { const updated = await withTransaction(async (client) => {
const result = await client.query( const result = await client.query(
'UPDATE habitats SET name = $1, image_path = $2, updated_by_user_id = $3, updated_at = now() WHERE id = $4', 'UPDATE habitats SET name = $1, is_event_item = $2, image_path = $3, updated_by_user_id = $4, updated_at = now() WHERE id = $5',
[cleanPayload.name, cleanPayload.imagePath, userId, id] [cleanPayload.name, cleanPayload.isEventItem, cleanPayload.imagePath, userId, id]
); );
if (result.rowCount === 0) { if (result.rowCount === 0) {
return false; return false;
@@ -3656,6 +3718,7 @@ function itemProjection(locale: string): string {
i.id, i.id,
${itemName} AS name, ${itemName} AS name,
i.name AS "baseName", i.name AS "baseName",
i.is_event_item AS "isEventItem",
${translationsSelect('items', 'i.id')} AS translations, ${translationsSelect('items', 'i.id')} AS translations,
${auditSelect('i', 'item_created_user', 'item_updated_user')}, ${auditSelect('i', 'item_created_user', 'item_updated_user')},
${uploadedImageJson('i.image_path')} AS image, ${uploadedImageJson('i.image_path')} AS image,
@@ -3873,7 +3936,13 @@ export async function getItem(id: number, locale = defaultLocale) {
query( query(
` `
SELECT SELECT
json_build_object('id', p.id, 'name', ${pokemonName}, 'image', ${pokemonImageJson('p')}) AS pokemon, json_build_object(
'id', p.id,
'displayId', p.display_id,
'name', ${pokemonName},
'isEventItem', p.is_event_item,
'image', ${pokemonImageJson('p')}
) AS pokemon,
json_build_object('id', s.id, 'name', ${skillName}) AS skill json_build_object('id', s.id, 'name', ${skillName}) AS skill
FROM pokemon_skill_item_drops psid FROM pokemon_skill_item_drops psid
JOIN pokemon p ON p.id = psid.pokemon_id JOIN pokemon p ON p.id = psid.pokemon_id
@@ -3905,6 +3974,7 @@ function cleanItemPayload(payload: Record<string, unknown>): ItemPayload {
dualDyeable: Boolean(payload.dualDyeable), dualDyeable: Boolean(payload.dualDyeable),
patternEditable: Boolean(payload.patternEditable), patternEditable: Boolean(payload.patternEditable),
noRecipe: Boolean(payload.noRecipe), noRecipe: Boolean(payload.noRecipe),
isEventItem: Boolean(payload.isEventItem),
acquisitionMethodIds: cleanIds(payload.acquisitionMethodIds), acquisitionMethodIds: cleanIds(payload.acquisitionMethodIds),
tagIds: cleanIds(payload.tagIds), tagIds: cleanIds(payload.tagIds),
imagePath: cleanUploadImagePath(payload.imagePath, 'items') imagePath: cleanUploadImagePath(payload.imagePath, 'items')
@@ -3956,12 +4026,13 @@ export async function createItem(payload: Record<string, unknown>, userId: numbe
dual_dyeable, dual_dyeable,
pattern_editable, pattern_editable,
no_recipe, no_recipe,
is_event_item,
image_path, image_path,
sort_order, sort_order,
created_by_user_id, created_by_user_id,
updated_by_user_id updated_by_user_id
) )
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $10) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $11)
RETURNING id RETURNING id
`, `,
[ [
@@ -3972,6 +4043,7 @@ export async function createItem(payload: Record<string, unknown>, userId: numbe
cleanPayload.dualDyeable, cleanPayload.dualDyeable,
cleanPayload.patternEditable, cleanPayload.patternEditable,
cleanPayload.noRecipe, cleanPayload.noRecipe,
cleanPayload.isEventItem,
cleanPayload.imagePath, cleanPayload.imagePath,
sortOrder, sortOrder,
userId userId
@@ -4003,10 +4075,11 @@ export async function updateItem(id: number, payload: Record<string, unknown>, u
dual_dyeable = $5, dual_dyeable = $5,
pattern_editable = $6, pattern_editable = $6,
no_recipe = $7, no_recipe = $7,
image_path = $8, is_event_item = $8,
updated_by_user_id = $9, image_path = $9,
updated_by_user_id = $10,
updated_at = now() updated_at = now()
WHERE id = $10 WHERE id = $11
`, `,
[ [
cleanPayload.name, cleanPayload.name,
@@ -4016,6 +4089,7 @@ export async function updateItem(id: number, payload: Record<string, unknown>, u
cleanPayload.dualDyeable, cleanPayload.dualDyeable,
cleanPayload.patternEditable, cleanPayload.patternEditable,
cleanPayload.noRecipe, cleanPayload.noRecipe,
cleanPayload.isEventItem,
cleanPayload.imagePath, cleanPayload.imagePath,
userId, userId,
id id

View File

@@ -12,6 +12,8 @@ const changeLabelKeys: Record<string, string> = {
Name: 'common.name', Name: 'common.name',
名字: 'common.name', 名字: 'common.name',
名称: 'common.name', 名称: 'common.name',
'Pokemon ID': 'pages.pokemon.id',
'Event item': 'common.eventItem',
Genus: 'pages.pokemon.genus', Genus: 'pages.pokemon.genus',
Details: 'pages.pokemon.details', Details: 'pages.pokemon.details',
介绍: 'pages.pokemon.details', 介绍: 'pages.pokemon.details',

View File

@@ -106,8 +106,10 @@ export interface EditHistoryEntry {
export interface Pokemon extends EditInfo { export interface Pokemon extends EditInfo {
id: number; id: number;
displayId: number;
name: string; name: string;
baseName?: string; baseName?: string;
isEventItem: boolean;
genus: string; genus: string;
baseGenus?: string; baseGenus?: string;
details: string; details: string;
@@ -127,7 +129,9 @@ export interface Pokemon extends EditInfo {
export interface RelatedPokemon { export interface RelatedPokemon {
id: number; id: number;
displayId: number;
name: string; name: string;
isEventItem: boolean;
image?: PokemonImage | null; image?: PokemonImage | null;
environment: NamedEntity; environment: NamedEntity;
skills: Skill[]; skills: Skill[];
@@ -155,6 +159,7 @@ export interface Habitat extends EditInfo {
id: number; id: number;
name: string; name: string;
baseName?: string; baseName?: string;
isEventItem: boolean;
translations?: TranslationMap; translations?: TranslationMap;
image: EntityImage | null; image: EntityImage | null;
recipe: Array<NamedEntity & { image?: EntityImage | null; quantity: number }>; recipe: Array<NamedEntity & { image?: EntityImage | null; quantity: number }>;
@@ -165,6 +170,8 @@ export interface HabitatDetail extends Habitat {
editHistory: EditHistoryEntry[]; editHistory: EditHistoryEntry[];
imageHistory: EntityImageUpload[]; imageHistory: EntityImageUpload[];
pokemon: Array<NamedEntity & { pokemon: Array<NamedEntity & {
displayId: number;
isEventItem: boolean;
image?: PokemonImage | null; image?: PokemonImage | null;
time_of_day: string; time_of_day: string;
weather: string; weather: string;
@@ -201,6 +208,7 @@ export interface Item extends EditInfo {
id: number; id: number;
name: string; name: string;
baseName?: string; baseName?: string;
isEventItem: boolean;
translations?: TranslationMap; translations?: TranslationMap;
image: EntityImage | null; image: EntityImage | null;
category: NamedEntity; category: NamedEntity;
@@ -223,7 +231,7 @@ export interface ItemDetail extends Item {
editHistory: EditHistoryEntry[]; editHistory: EditHistoryEntry[];
imageHistory: EntityImageUpload[]; imageHistory: EntityImageUpload[];
droppedByPokemon: Array<{ droppedByPokemon: Array<{
pokemon: NamedEntity & { image?: PokemonImage | null }; pokemon: NamedEntity & { displayId: number; isEventItem: boolean; image?: PokemonImage | null };
skill: NamedEntity; skill: NamedEntity;
}>; }>;
} }
@@ -339,7 +347,8 @@ export type ConfigType =
| 'life-tags'; | 'life-tags';
export interface PokemonPayload { export interface PokemonPayload {
id: number; displayId: number;
isEventItem: boolean;
name: string; name: string;
genus: string; genus: string;
details: string; details: string;
@@ -388,6 +397,7 @@ export interface ItemPayload {
dualDyeable: boolean; dualDyeable: boolean;
patternEditable: boolean; patternEditable: boolean;
noRecipe: boolean; noRecipe: boolean;
isEventItem: boolean;
acquisitionMethodIds: number[]; acquisitionMethodIds: number[];
tagIds: number[]; tagIds: number[];
imagePath: string; imagePath: string;
@@ -402,6 +412,7 @@ export interface RecipePayload {
export interface HabitatPayload { export interface HabitatPayload {
name: string; name: string;
translations?: TranslationMap; translations?: TranslationMap;
isEventItem: boolean;
imagePath: string; imagePath: string;
recipeItems: Array<{ itemId: number; quantity: number }>; recipeItems: Array<{ itemId: number; quantity: number }>;
pokemonAppearances: Array<{ pokemonAppearances: Array<{

View File

@@ -188,7 +188,7 @@ const languageLabel = (item: Language) => item.name;
const configKey = (item: EditableConfig) => item.id; const configKey = (item: EditableConfig) => item.id;
const configLabel = (item: EditableConfig) => item.name; const configLabel = (item: EditableConfig) => item.name;
const pokemonKey = (item: Pokemon) => item.id; const pokemonKey = (item: Pokemon) => item.id;
const pokemonLabel = (item: Pokemon) => `#${item.id} ${item.name}`; const pokemonLabel = (item: Pokemon) => `#${item.displayId} ${item.name}`;
const itemKey = (item: Item) => item.id; const itemKey = (item: Item) => item.id;
const itemLabel = (item: Item) => item.name; const itemLabel = (item: Item) => item.name;
const recipeKey = (item: Recipe) => item.id; const recipeKey = (item: Recipe) => item.id;
@@ -921,7 +921,7 @@ onMounted(() => {
@reorder="persistPokemonOrder" @reorder="persistPokemonOrder"
> >
<template #default="{ item }"> <template #default="{ item }">
<RouterLink :to="`/pokemon/${item.id}`">#{{ item.id }} {{ item.name }}</RouterLink> <RouterLink :to="`/pokemon/${item.id}`">#{{ item.displayId }} {{ item.name }}</RouterLink>
<span class="row-actions"> <span class="row-actions">
<button type="button" :disabled="busy" @click="removePokemon(item.id)"> <button type="button" :disabled="busy" @click="removePokemon(item.id)">
<Icon :icon="iconDelete" class="ui-icon" aria-hidden="true" /> <Icon :icon="iconDelete" class="ui-icon" aria-hidden="true" />

View File

@@ -49,6 +49,7 @@ const creatingSelect = ref('');
const habitatForm = ref({ const habitatForm = ref({
name: '', name: '',
translations: {} as TranslationMap, translations: {} as TranslationMap,
isEventItem: false,
imagePath: '', imagePath: '',
recipeItems: [] as Array<{ itemId: string; quantity: number }>, recipeItems: [] as Array<{ itemId: string; quantity: number }>,
pokemonAppearances: [] as HabitatAppearanceForm[] pokemonAppearances: [] as HabitatAppearanceForm[]
@@ -71,7 +72,7 @@ const routeId = computed(() => (typeof route.params.id === 'string' ? route.para
const isEditing = computed(() => routeId.value !== ''); const isEditing = computed(() => routeId.value !== '');
const itemSelectOptions = computed(() => itemRows.value.map((item) => ({ id: item.id, name: item.name }))); const itemSelectOptions = computed(() => itemRows.value.map((item) => ({ id: item.id, name: item.name })));
const pokemonSelectOptions = computed(() => const pokemonSelectOptions = computed(() =>
pokemonRows.value.map((pokemon) => ({ id: pokemon.id, name: pokemon.name, label: `#${pokemon.id} ${pokemon.name}` })) pokemonRows.value.map((pokemon) => ({ id: pokemon.id, name: pokemon.name, label: `#${pokemon.displayId} ${pokemon.name}` }))
); );
const pageTitle = computed(() => const pageTitle = computed(() =>
isEditing.value isEditing.value
@@ -166,6 +167,7 @@ async function loadEditor() {
habitatForm.value = { habitatForm.value = {
name: habitat.baseName ?? habitat.name, name: habitat.baseName ?? habitat.name,
translations: habitat.translations ?? {}, translations: habitat.translations ?? {},
isEventItem: habitat.isEventItem,
imagePath: habitat.image?.path ?? '', imagePath: habitat.image?.path ?? '',
recipeItems: habitat.recipe.map((recipeItem) => ({ itemId: String(recipeItem.id), quantity: recipeItem.quantity })), recipeItems: habitat.recipe.map((recipeItem) => ({ itemId: String(recipeItem.id), quantity: recipeItem.quantity })),
pokemonAppearances: groupPokemonAppearances(habitat) pokemonAppearances: groupPokemonAppearances(habitat)
@@ -212,6 +214,7 @@ async function saveHabitat() {
const payload: HabitatPayload = { const payload: HabitatPayload = {
name: habitatNameForSave(), name: habitatNameForSave(),
translations: habitatForm.value.translations, translations: habitatForm.value.translations,
isEventItem: habitatForm.value.isEventItem,
imagePath: habitatForm.value.imagePath, imagePath: habitatForm.value.imagePath,
recipeItems: toQuantityRows(habitatForm.value.recipeItems), recipeItems: toQuantityRows(habitatForm.value.recipeItems),
pokemonAppearances: habitatForm.value.pokemonAppearances pokemonAppearances: habitatForm.value.pokemonAppearances
@@ -276,6 +279,10 @@ onMounted(() => {
@error="message = $event" @error="message = $event"
/> />
<div class="check-row">
<label><input v-model="habitatForm.isEventItem" type="checkbox" /> {{ t('pages.habitats.eventItem') }}</label>
</div>
<div class="field"> <div class="field">
<label>{{ t('pages.habitats.recipe') }}</label> <label>{{ t('pages.habitats.recipe') }}</label>
<div v-for="(row, index) in habitatForm.recipeItems" :key="index" class="inline-row"> <div v-for="(row, index) in habitatForm.recipeItems" :key="index" class="inline-row">

View File

@@ -258,7 +258,7 @@ watch(
<img v-if="entry.pokemon.image" :src="entry.pokemon.image.url" alt="" loading="lazy" /> <img v-if="entry.pokemon.image" :src="entry.pokemon.image.url" alt="" loading="lazy" />
<PokeBallMark v-else size="22px" /> <PokeBallMark v-else size="22px" />
</span> </span>
<span>#{{ entry.pokemon.id }} {{ entry.pokemon.name }}</span> <span>#{{ entry.pokemon.displayId }} {{ entry.pokemon.name }}</span>
</RouterLink> </RouterLink>
<span>{{ t('pages.pokemon.skillDrop', { name: entry.skill.name }) }}</span> <span>{{ t('pages.pokemon.skillDrop', { name: entry.skill.name }) }}</span>
</li> </li>

View File

@@ -32,6 +32,7 @@ const itemForm = ref({
dualDyeable: false, dualDyeable: false,
patternEditable: false, patternEditable: false,
noRecipe: false, noRecipe: false,
isEventItem: false,
acquisitionMethodIds: [] as string[], acquisitionMethodIds: [] as string[],
tagIds: [] as string[], tagIds: [] as string[],
imagePath: '' imagePath: ''
@@ -92,6 +93,7 @@ async function loadEditor() {
dualDyeable: item.customization.dualDyeable, dualDyeable: item.customization.dualDyeable,
patternEditable: item.customization.patternEditable, patternEditable: item.customization.patternEditable,
noRecipe: item.noRecipe, noRecipe: item.noRecipe,
isEventItem: item.isEventItem,
acquisitionMethodIds: item.acquisitionMethods.map((method) => String(method.id)), acquisitionMethodIds: item.acquisitionMethods.map((method) => String(method.id)),
tagIds: item.tags.map((tag) => String(tag.id)), tagIds: item.tags.map((tag) => String(tag.id)),
imagePath: item.image?.path ?? '' imagePath: item.image?.path ?? ''
@@ -158,6 +160,7 @@ async function saveItem() {
dualDyeable: itemForm.value.dualDyeable, dualDyeable: itemForm.value.dualDyeable,
patternEditable: itemForm.value.patternEditable, patternEditable: itemForm.value.patternEditable,
noRecipe: itemForm.value.noRecipe, noRecipe: itemForm.value.noRecipe,
isEventItem: itemForm.value.isEventItem,
acquisitionMethodIds: toIds(itemForm.value.acquisitionMethodIds), acquisitionMethodIds: toIds(itemForm.value.acquisitionMethodIds),
tagIds: toIds(itemForm.value.tagIds), tagIds: toIds(itemForm.value.tagIds),
imagePath: itemForm.value.imagePath imagePath: itemForm.value.imagePath
@@ -249,6 +252,7 @@ onMounted(() => {
<label><input v-model="itemForm.dualDyeable" type="checkbox" /> {{ t('pages.items.dualDyeable') }}</label> <label><input v-model="itemForm.dualDyeable" type="checkbox" /> {{ t('pages.items.dualDyeable') }}</label>
<label><input v-model="itemForm.patternEditable" type="checkbox" /> {{ t('pages.items.patternEditable') }}</label> <label><input v-model="itemForm.patternEditable" type="checkbox" /> {{ t('pages.items.patternEditable') }}</label>
<label><input v-model="itemForm.noRecipe" type="checkbox" :disabled="hasRecipe" /> {{ t('pages.items.noRecipe') }}</label> <label><input v-model="itemForm.noRecipe" type="checkbox" :disabled="hasRecipe" /> {{ t('pages.items.noRecipe') }}</label>
<label><input v-model="itemForm.isEventItem" type="checkbox" /> {{ t('pages.items.eventItem') }}</label>
</div> </div>
<div class="field"> <div class="field">

View File

@@ -304,7 +304,7 @@ watch(
</div> </div>
</section> </section>
<section v-else class="page-stack"> <section v-else class="page-stack">
<PageHeader :title="`#${pokemon.id} ${pokemon.name}`" :subtitle="t('pages.pokemon.environmentPrefix', { name: pokemon.environment.name })"> <PageHeader :title="`#${pokemon.displayId} ${pokemon.name}`" :subtitle="t('pages.pokemon.environmentPrefix', { name: pokemon.environment.name })">
<template #kicker>Pokédex Detail</template> <template #kicker>Pokédex Detail</template>
<template #actions> <template #actions>
<RouterLink class="ui-button ui-button--primary ui-button--small" :to="`/pokemon/${pokemon.id}/edit`"> <RouterLink class="ui-button ui-button--primary ui-button--small" :to="`/pokemon/${pokemon.id}/edit`">
@@ -421,7 +421,7 @@ watch(
</span> </span>
<div class="related-pokemon-row"> <div class="related-pokemon-row">
<div class="related-pokemon-row__summary"> <div class="related-pokemon-row__summary">
<RouterLink class="related-pokemon-row__name" :to="`/pokemon/${related.id}`">#{{ related.id }} {{ related.name }}</RouterLink> <RouterLink class="related-pokemon-row__name" :to="`/pokemon/${related.id}`">#{{ related.displayId }} {{ related.name }}</RouterLink>
<div class="related-pokemon-row__traits"> <div class="related-pokemon-row__traits">
<EntityChips <EntityChips
v-if="related.skills.length" v-if="related.skills.length"

View File

@@ -70,6 +70,7 @@ function defaultPokemonStats(): PokemonStats {
const pokemonForm = ref({ const pokemonForm = ref({
id: '', id: '',
isEventItem: false,
name: '', name: '',
genus: '', genus: '',
details: '', details: '',
@@ -206,7 +207,7 @@ function pokemonNameForSave() {
} }
function pokemonIdForSave() { function pokemonIdForSave() {
return Number(isEditing.value ? routeId.value : pokemonForm.value.id); return Number(pokemonForm.value.id);
} }
function mergeFetchedTranslations(fetchedTranslations: TranslationMap | undefined): TranslationMap { function mergeFetchedTranslations(fetchedTranslations: TranslationMap | undefined): TranslationMap {
@@ -273,7 +274,8 @@ async function loadEditor() {
if (isEditing.value) { if (isEditing.value) {
const pokemon = await api.pokemonDetail(routeId.value); const pokemon = await api.pokemonDetail(routeId.value);
pokemonForm.value = { pokemonForm.value = {
id: String(pokemon.id), id: String(pokemon.displayId),
isEventItem: pokemon.isEventItem,
name: pokemon.baseName ?? pokemon.name, name: pokemon.baseName ?? pokemon.name,
genus: pokemon.baseGenus ?? pokemon.genus, genus: pokemon.baseGenus ?? pokemon.genus,
details: pokemon.baseDetails ?? pokemon.details, details: pokemon.baseDetails ?? pokemon.details,
@@ -535,7 +537,8 @@ async function savePokemon() {
try { try {
const payload: PokemonPayload = { const payload: PokemonPayload = {
id: pokemonIdForSave(), displayId: pokemonIdForSave(),
isEventItem: pokemonForm.value.isEventItem,
name: pokemonNameForSave(), name: pokemonNameForSave(),
genus: pokemonForm.value.genus, genus: pokemonForm.value.genus,
details: pokemonForm.value.details, details: pokemonForm.value.details,
@@ -630,8 +633,8 @@ watch(fetchIdentifier, refreshFetchOptions);
<section v-if="activeEditTab === 'basic'" class="pokemon-edit-panel" role="tabpanel" :aria-label="t('pages.pokemon.editTabBasic')"> <section v-if="activeEditTab === 'basic'" class="pokemon-edit-panel" role="tabpanel" :aria-label="t('pages.pokemon.editTabBasic')">
<div class="pokemon-edit-grid"> <div class="pokemon-edit-grid">
<div class="field"> <div class="field">
<label for="pokemon-id">ID</label> <label for="pokemon-id">{{ t('pages.pokemon.id') }}</label>
<input id="pokemon-id" v-model="pokemonForm.id" :disabled="isEditing" min="1" required type="number" /> <input id="pokemon-id" v-model="pokemonForm.id" min="1" required type="number" />
</div> </div>
<TranslationFields <TranslationFields
@@ -645,6 +648,10 @@ watch(fetchIdentifier, refreshFetchOptions);
/> />
</div> </div>
<div class="check-row">
<label><input v-model="pokemonForm.isEventItem" type="checkbox" /> {{ t('pages.pokemon.eventItem') }}</label>
</div>
<div class="pokemon-edit-grid"> <div class="pokemon-edit-grid">
<div class="field"> <div class="field">
<label for="pokemon-environment">{{ t('pages.pokemon.environment') }}</label> <label for="pokemon-environment">{{ t('pages.pokemon.environment') }}</label>

View File

@@ -134,7 +134,7 @@ watch(query, loadPokemon);
<EntityCard <EntityCard
v-for="item in pokemon" v-for="item in pokemon"
:key="item.id" :key="item.id"
:title="`#${item.id} ${item.name}`" :title="`#${item.displayId} ${item.name}`"
:to="`/pokemon/${item.id}`" :to="`/pokemon/${item.id}`"
:image="pokemonCardImage(item)" :image="pokemonCardImage(item)"
/> />

View File

@@ -38,6 +38,7 @@ export const systemWordingMessages = {
inDev: 'In-Dev', inDev: 'In-Dev',
removeNamed: 'Remove {name}', removeNamed: 'Remove {name}',
quantity: 'Quantity', quantity: 'Quantity',
eventItem: 'Event item',
required: 'Required' required: 'Required'
}, },
nav: { nav: {
@@ -133,6 +134,7 @@ export const systemWordingMessages = {
editTabAdvance: 'Advance', editTabAdvance: 'Advance',
newTitle: 'New Pokemon', newTitle: 'New Pokemon',
editTitle: 'Edit #{id} {name}', editTitle: 'Edit #{id} {name}',
id: 'Pokemon ID',
fetchData: 'Fetch data', fetchData: 'Fetch data',
fetchingData: 'Fetching', fetchingData: 'Fetching',
fetchIdentifier: 'Data identifier', fetchIdentifier: 'Data identifier',
@@ -155,6 +157,7 @@ export const systemWordingMessages = {
clearImage: 'Clear image', clearImage: 'Clear image',
imageEmpty: 'No Pokemon image selected', imageEmpty: 'No Pokemon image selected',
imageAlt: '{name} {variant} image', imageAlt: '{name} {variant} image',
eventItem: 'Event item',
loadingList: 'Loading Pokemon list', loadingList: 'Loading Pokemon list',
loadingDetail: 'Loading Pokemon detail', loadingDetail: 'Loading Pokemon detail',
loadingEdit: 'Loading Pokemon editor', loadingEdit: 'Loading Pokemon editor',
@@ -221,6 +224,7 @@ export const systemWordingMessages = {
loadingList: 'Loading habitat list', loadingList: 'Loading habitat list',
loadingDetail: 'Loading habitat detail', loadingDetail: 'Loading habitat detail',
loadingEdit: 'Loading habitat editor', loadingEdit: 'Loading habitat editor',
eventItem: 'Event item',
recipe: 'Recipe', recipe: 'Recipe',
recipeList: 'Recipe list', recipeList: 'Recipe list',
possiblePokemon: 'Possible Pokemon', possiblePokemon: 'Possible Pokemon',
@@ -251,6 +255,7 @@ export const systemWordingMessages = {
dualDyeable: 'Dual dyeable', dualDyeable: 'Dual dyeable',
patternEditable: 'Pattern editable', patternEditable: 'Pattern editable',
noRecipe: 'No recipe', noRecipe: 'No recipe',
eventItem: 'Event item',
recipeInfo: 'Recipe info', recipeInfo: 'Recipe info',
relatedRecipes: 'Related recipes', relatedRecipes: 'Related recipes',
relatedHabitats: 'Related habitats', relatedHabitats: 'Related habitats',
@@ -686,6 +691,7 @@ export const systemWordingMessages = {
inDev: '开发中', inDev: '开发中',
removeNamed: '移除{name}', removeNamed: '移除{name}',
quantity: '数量', quantity: '数量',
eventItem: '活动物品',
required: '必填' required: '必填'
}, },
nav: { nav: {
@@ -781,6 +787,7 @@ export const systemWordingMessages = {
editTabAdvance: '进阶', editTabAdvance: '进阶',
newTitle: '新增 Pokemon', newTitle: '新增 Pokemon',
editTitle: '编辑 #{id} {name}', editTitle: '编辑 #{id} {name}',
id: 'Pokemon ID',
fetchData: '获取数据', fetchData: '获取数据',
fetchingData: '正在获取', fetchingData: '正在获取',
fetchIdentifier: '数据标识', fetchIdentifier: '数据标识',
@@ -803,6 +810,7 @@ export const systemWordingMessages = {
clearImage: '清除图片', clearImage: '清除图片',
imageEmpty: '尚未选择 Pokemon 图片', imageEmpty: '尚未选择 Pokemon 图片',
imageAlt: '{name} {variant} 图片', imageAlt: '{name} {variant} 图片',
eventItem: '活动物品',
loadingList: '正在加载 Pokemon 列表', loadingList: '正在加载 Pokemon 列表',
loadingDetail: '正在加载 Pokemon 详情', loadingDetail: '正在加载 Pokemon 详情',
loadingEdit: '正在加载 Pokemon 编辑内容', loadingEdit: '正在加载 Pokemon 编辑内容',
@@ -869,6 +877,7 @@ export const systemWordingMessages = {
loadingList: '正在加载栖息地列表', loadingList: '正在加载栖息地列表',
loadingDetail: '正在加载栖息地详情', loadingDetail: '正在加载栖息地详情',
loadingEdit: '正在加载栖息地编辑内容', loadingEdit: '正在加载栖息地编辑内容',
eventItem: '活动物品',
recipe: '配方', recipe: '配方',
recipeList: '配方列表', recipeList: '配方列表',
possiblePokemon: '可能出现的宝可梦', possiblePokemon: '可能出现的宝可梦',
@@ -899,6 +908,7 @@ export const systemWordingMessages = {
dualDyeable: '可双区染色', dualDyeable: '可双区染色',
patternEditable: '可改花纹', patternEditable: '可改花纹',
noRecipe: '无材料单', noRecipe: '无材料单',
eventItem: '活动物品',
recipeInfo: '材料单信息', recipeInfo: '材料单信息',
relatedRecipes: '相关材料单', relatedRecipes: '相关材料单',
relatedHabitats: '相关栖息地', relatedHabitats: '相关栖息地',