feat(pokemon): add types, stats, genus, dimensions, and details

Update schema and API to support expanded Pokemon profile fields
Add UI for editing and displaying types, base stats, and dimensions
Support translations for details and genus fields
This commit is contained in:
2026-05-01 17:58:33 +08:00
parent ec3494ea28
commit 49aae3bd7c
15 changed files with 996 additions and 18 deletions

View File

@@ -49,6 +49,7 @@
- 支持翻译的实体:
- Pokemon
- 特长
- Pokemon Types
- 喜欢的环境
- 喜欢的东西 / 标签
- 物品分类
@@ -61,7 +62,9 @@
- 支持翻译的字段:
- `name`
- `title`
- 实体仍保留基础 `name``title` 字段,默认语言内容以基础字段为准。
- `details`:仅 Pokemon 介绍使用
- `genus`:仅 Pokemon Genus 使用
- 实体仍保留基础 `name``title``details``genus` 字段,默认语言内容以基础字段为准。
- API 返回展示名称时按当前语言解析,回退顺序为:请求语言翻译 -> 默认语言翻译 -> 基础字段。
- 编辑表单必须避免本地化 UI 覆盖基础名称;翻译字段只展示当前需要编辑的语言。
@@ -126,6 +129,12 @@
- 已移除 `subcategory` 字段。
- 当特长允许掉落物时Pokemon 编辑中可为该 Pokemon + 特长配置一个掉落物品。
### Pokemon Types
- 名称
- 用于 Pokemon 属性配置。
- Pokemon 可选择 1 到 2 个 Type用于表达双属性。
### 喜欢的环境
- 名称
@@ -163,10 +172,23 @@ Pokemon 可配置:
- ID
- 名称
- Genus可为空支持翻译
- 介绍 / Details可为空支持翻译
- Height默认输入 `ft/in`,可切换输入 `m`;详情页同时展示 `ft/in``m`
- Weight默认输入磅 `lb`,可切换输入 `kg`;详情页同时展示 `lbs``kg`
- Height / Weight 换算结果四舍五入;`m` / `kg` 保留 2 位小数,`in` 取整数,`lb` 保留 1 位小数。
- Types可多选最多 2 个
- 喜欢的环境:单选
- 特长:可多选,最多 2 个
- 特长掉落物品:按 Pokemon + 特长配置,单选物品
- 喜欢的东西:可多选,最多 6 个
- 六维:
- HP
- Attack
- Defense
- Special Attack
- Special Defense
- Speed
- 出现的栖息地:由栖息地出现配置反向展示
- 翻译
- 排序
@@ -186,6 +208,11 @@ Pokemon 列表功能:
Pokemon 详情页展示:
- 基本信息
- 主内容顶部按以下布局展示:
- 左上Genus & Details无区块标题如有 Genus先展示 Genus再以分割线连接 Details 内容
- 左下Height / Weight 与 Types 按 2:1 比例并排Height / Weight 无区块标题,在 Dimension 区内左右并排展示并以中间分割线隔开每组按英制、分割线、公制、标签上下排列Types 不显示 Type 1 / Type 2 文案,上下布局并居中展示
- 右侧:六维 Stats
- 六维使用 ProgressBar 展示,最大值按 150 计算。
- 特长
- 特长掉落物品
- 喜欢的环境
@@ -193,7 +220,7 @@ Pokemon 详情页展示:
- 关联喜欢的东西的物品
- 出现的栖息地
- 最后编辑信息
- 编辑历史
- 编辑历史:保留在右侧 Sidebar 展示
## 物品

View File

@@ -28,6 +28,7 @@ CREATE TABLE IF NOT EXISTS entity_translations (
entity_type text NOT NULL CHECK (
entity_type IN (
'pokemon',
'pokemon-types',
'skills',
'environments',
'favorite-things',
@@ -42,7 +43,7 @@ CREATE TABLE IF NOT EXISTS entity_translations (
),
entity_id integer NOT NULL,
locale text NOT NULL REFERENCES languages(code) ON DELETE CASCADE,
field_name text NOT NULL CHECK (field_name IN ('name', 'title')),
field_name text NOT NULL CHECK (field_name IN ('name', 'title', 'details', 'genus')),
value text NOT NULL,
PRIMARY KEY (entity_type, entity_id, locale, field_name)
);
@@ -50,6 +51,26 @@ CREATE TABLE IF NOT EXISTS entity_translations (
CREATE INDEX IF NOT EXISTS entity_translations_lookup_idx
ON entity_translations (entity_type, entity_id, field_name, locale);
ALTER TABLE entity_translations DROP CONSTRAINT IF EXISTS entity_translations_entity_type_check;
ALTER TABLE entity_translations ADD CONSTRAINT entity_translations_entity_type_check CHECK (
entity_type IN (
'pokemon',
'pokemon-types',
'skills',
'environments',
'favorite-things',
'item-categories',
'item-usages',
'acquisition-methods',
'items',
'maps',
'habitats',
'daily-checklist-items'
)
);
ALTER TABLE entity_translations DROP CONSTRAINT IF EXISTS entity_translations_field_name_check;
ALTER TABLE entity_translations ADD CONSTRAINT entity_translations_field_name_check CHECK (field_name IN ('name', 'title', 'details', 'genus'));
CREATE TABLE IF NOT EXISTS users (
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
email text NOT NULL UNIQUE,
@@ -115,13 +136,37 @@ CREATE TABLE IF NOT EXISTS favorite_things (
sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0)
);
CREATE TABLE IF NOT EXISTS pokemon_types (
id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
name text NOT NULL UNIQUE,
sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0)
);
CREATE TABLE IF NOT EXISTS pokemon (
id integer PRIMARY KEY,
name text NOT NULL UNIQUE,
genus text NOT NULL DEFAULT '',
details text NOT NULL DEFAULT '',
height_inches double precision NOT NULL DEFAULT 0 CHECK (height_inches >= 0),
weight_pounds double precision NOT NULL DEFAULT 0 CHECK (weight_pounds >= 0),
environment_id integer NOT NULL REFERENCES environments(id),
hp integer NOT NULL DEFAULT 0 CHECK (hp >= 0),
attack integer NOT NULL DEFAULT 0 CHECK (attack >= 0),
defense integer NOT NULL DEFAULT 0 CHECK (defense >= 0),
special_attack integer NOT NULL DEFAULT 0 CHECK (special_attack >= 0),
special_defense integer NOT NULL DEFAULT 0 CHECK (special_defense >= 0),
speed integer NOT NULL DEFAULT 0 CHECK (speed >= 0),
sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0)
);
CREATE TABLE IF NOT EXISTS pokemon_pokemon_types (
pokemon_id integer NOT NULL REFERENCES pokemon(id) ON DELETE CASCADE,
type_id integer NOT NULL REFERENCES pokemon_types(id) ON DELETE CASCADE,
slot_order integer NOT NULL CHECK (slot_order BETWEEN 1 AND 2),
PRIMARY KEY (pokemon_id, type_id),
UNIQUE (pokemon_id, slot_order)
);
CREATE TABLE IF NOT EXISTS pokemon_skills (
pokemon_id integer NOT NULL REFERENCES pokemon(id) ON DELETE CASCADE,
skill_id integer NOT NULL REFERENCES skills(id),
@@ -289,11 +334,27 @@ ALTER TABLE favorite_things ADD COLUMN IF NOT EXISTS created_at timestamptz NOT
ALTER TABLE favorite_things ADD COLUMN IF NOT EXISTS updated_at timestamptz NOT NULL DEFAULT now();
ALTER TABLE favorite_things ADD COLUMN IF NOT EXISTS sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0);
ALTER TABLE pokemon_types ADD COLUMN IF NOT EXISTS created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL;
ALTER TABLE pokemon_types ADD COLUMN IF NOT EXISTS updated_by_user_id integer REFERENCES users(id) ON DELETE SET NULL;
ALTER TABLE pokemon_types ADD COLUMN IF NOT EXISTS created_at timestamptz NOT NULL DEFAULT now();
ALTER TABLE pokemon_types ADD COLUMN IF NOT EXISTS updated_at timestamptz NOT NULL DEFAULT now();
ALTER TABLE pokemon_types ADD COLUMN IF NOT EXISTS sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0);
ALTER TABLE pokemon ADD COLUMN IF NOT EXISTS created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL;
ALTER TABLE pokemon ADD COLUMN IF NOT EXISTS updated_by_user_id integer REFERENCES users(id) ON DELETE SET NULL;
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 sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0);
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 height_inches double precision NOT NULL DEFAULT 0 CHECK (height_inches >= 0);
ALTER TABLE pokemon ADD COLUMN IF NOT EXISTS weight_pounds double precision NOT NULL DEFAULT 0 CHECK (weight_pounds >= 0);
ALTER TABLE pokemon ADD COLUMN IF NOT EXISTS hp integer NOT NULL DEFAULT 0 CHECK (hp >= 0);
ALTER TABLE pokemon ADD COLUMN IF NOT EXISTS attack integer NOT NULL DEFAULT 0 CHECK (attack >= 0);
ALTER TABLE pokemon ADD COLUMN IF NOT EXISTS defense integer NOT NULL DEFAULT 0 CHECK (defense >= 0);
ALTER TABLE pokemon ADD COLUMN IF NOT EXISTS special_attack integer NOT NULL DEFAULT 0 CHECK (special_attack >= 0);
ALTER TABLE pokemon ADD COLUMN IF NOT EXISTS special_defense integer NOT NULL DEFAULT 0 CHECK (special_defense >= 0);
ALTER TABLE pokemon ADD COLUMN IF NOT EXISTS speed integer NOT NULL DEFAULT 0 CHECK (speed >= 0);
ALTER TABLE item_categories ADD COLUMN IF NOT EXISTS created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL;
ALTER TABLE item_categories ADD COLUMN IF NOT EXISTS updated_by_user_id integer REFERENCES users(id) ON DELETE SET NULL;
@@ -367,6 +428,16 @@ SET sort_order = ordered.next_sort_order
FROM ordered
WHERE target.id = ordered.id;
WITH ordered AS (
SELECT id, (row_number() OVER (ORDER BY created_at, id) * 10)::integer AS next_sort_order
FROM pokemon_types
WHERE sort_order = 0
)
UPDATE pokemon_types target
SET sort_order = ordered.next_sort_order
FROM ordered
WHERE target.id = ordered.id;
WITH ordered AS (
SELECT id, (row_number() OVER (ORDER BY created_at, id) * 10)::integer AS next_sort_order
FROM pokemon
@@ -450,6 +521,7 @@ WHERE target.id = ordered.id;
CREATE INDEX IF NOT EXISTS environments_sort_order_idx ON environments(sort_order, id);
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 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 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);

View File

@@ -8,10 +8,11 @@ type QueryParams = Record<string, QueryValue>;
type DbClient = PoolClient;
type TranslationField = 'name' | 'title';
type TranslationField = 'name' | 'title' | 'details' | 'genus';
type TranslationInput = Record<string, Partial<Record<TranslationField, unknown>>>;
type EntityType =
| 'pokemon'
| 'pokemon-types'
| 'skills'
| 'environments'
| 'favorite-things'
@@ -24,6 +25,7 @@ type EntityType =
| 'daily-checklist-items';
type ConfigType =
| 'pokemon-types'
| 'skills'
| 'environments'
| 'favorite-things'
@@ -53,10 +55,25 @@ type SkillItemDrop = {
itemId: number;
};
type PokemonStats = {
hp: number;
attack: number;
defense: number;
specialAttack: number;
specialDefense: number;
speed: number;
};
type PokemonPayload = {
id: number;
name: string;
genus: string;
details: string;
heightInches: number;
weightPounds: number;
translations: TranslationInput;
typeIds: number[];
stats: PokemonStats;
environmentId: number;
skillIds: number[];
favoriteThingIds: number[];
@@ -123,6 +140,12 @@ type EditHistoryEntry = {
};
type PokemonChangeSource = {
name: string;
genus: string;
details: string;
heightInches: number;
weightPounds: number;
types: Array<{ name: string }>;
stats: PokemonStats;
environment: { name: string };
skills: Array<{ name: string; itemDrop?: { name: string } | null }>;
favorite_things: Array<{ name: string }>;
@@ -151,8 +174,17 @@ const timeOfDays = ['早晨', '中午', '傍晚', '晚上'];
const weathers = ['晴天', '阴天', '雨天'];
const defaultLocale = 'en';
const localePattern = /^[a-z]{2}(-[A-Z]{2})?$/;
const pokemonStatLabels: Array<{ key: keyof PokemonStats; label: string }> = [
{ key: 'hp', label: 'HP' },
{ key: 'attack', label: 'Attack' },
{ key: 'defense', label: 'Defense' },
{ key: 'specialAttack', label: 'Special Attack' },
{ key: 'specialDefense', label: 'Special Defense' },
{ key: 'speed', label: 'Speed' }
];
const configDefinitions: Record<ConfigType, ConfigDefinition> = {
'pokemon-types': { table: 'pokemon_types', entityType: 'pokemon-types' },
skills: { table: 'skills', entityType: 'skills', hasItemDrop: true },
environments: { table: 'environments', entityType: 'environments' },
'favorite-things': { table: 'favorite_things', entityType: 'favorite-things' },
@@ -377,6 +409,10 @@ function cleanName(value: unknown, message = 'Name is required'): string {
return value.trim();
}
function cleanOptionalText(value: unknown): string {
return typeof value === 'string' ? value.trim() : '';
}
function cleanIds(value: unknown): number[] {
if (!Array.isArray(value)) {
return [];
@@ -388,6 +424,27 @@ function cleanIdValues(value: unknown): number[] {
return cleanIds(Array.isArray(value) ? value : [value]);
}
function cleanPokemonStats(value: unknown): PokemonStats {
const row = value && typeof value === 'object' && !Array.isArray(value) ? (value as Record<string, unknown>) : {};
return pokemonStatLabels.reduce((stats, stat) => {
const numberValue = Number(row[stat.key] ?? 0);
if (!Number.isInteger(numberValue) || numberValue < 0) {
throw validationError(`${stat.label} must be a non-negative integer`);
}
return { ...stats, [stat.key]: numberValue };
}, {} as PokemonStats);
}
function cleanNonNegativeNumber(value: unknown, message: string): number {
const numberValue = Number(value ?? 0);
if (!Number.isFinite(numberValue) || numberValue < 0) {
throw validationError(message);
}
return numberValue;
}
function cleanQuantities(value: unknown): IdQuantity[] {
if (!Array.isArray(value)) {
return [];
@@ -687,6 +744,36 @@ function skillDropListValue(skills: Array<{ name: string; itemDrop?: { name: str
return rows.length ? rows.join(' / ') : 'None';
}
function pokemonStatsValue(stats: PokemonStats | null | undefined): string {
return pokemonStatLabels.map((stat) => `${stat.label}: ${stats?.[stat.key] ?? 0}`).join(' / ');
}
function roundMeasure(value: number, precision: number): number {
const scale = 10 ** precision;
return Math.round(value * scale) / scale;
}
function formatFixedMeasure(value: number, precision: number): string {
return value.toFixed(precision);
}
function feetInchesValue(inches: number): string {
const totalInches = Math.round(inches);
const feet = Math.floor(totalInches / 12);
const remainingInches = totalInches - feet * 12;
return `${feet}'${remainingInches}"`;
}
function pokemonHeightValue(inches: number | null | undefined): string {
const value = inches ?? 0;
return `${feetInchesValue(value)} / ${formatFixedMeasure(roundMeasure(value * 0.0254, 2), 2)} m`;
}
function pokemonWeightValue(pounds: number | null | undefined): string {
const value = pounds ?? 0;
return `${formatFixedMeasure(roundMeasure(value, 1), 1)} lb / ${formatFixedMeasure(roundMeasure(value * 0.45359237, 2), 2)} kg`;
}
function appearanceListValue(
rows: Array<{ name: string; time_of_day: string; weather: string; rarity: number; map: { name: string } }> | null | undefined
): string {
@@ -742,6 +829,7 @@ async function pokemonEditChanges(
): Promise<EditChange[]> {
const changes: EditChange[] = [];
const environmentNames = await entityNameMap(client, 'environments', [after.environmentId]);
const typeNames = await entityNameMap(client, 'pokemon_types', after.typeIds);
const skillNames = await entityNameMap(client, 'skills', after.skillIds);
const favoriteThingNames = await entityNameMap(client, 'favorite_things', after.favoriteThingIds);
const dropSkillNames = await entityNameMap(client, 'skills', after.skillItemDrops.map((drop) => drop.skillId));
@@ -757,6 +845,12 @@ async function pokemonEditChanges(
.join(' / ');
pushChange(changes, 'Name', before.name, after.name);
pushChange(changes, 'Genus', before.genus, after.genus);
pushChange(changes, 'Details', before.details, after.details);
pushChange(changes, 'Height', pokemonHeightValue(before.heightInches), pokemonHeightValue(after.heightInches));
pushChange(changes, 'Weight', pokemonWeightValue(before.weightPounds), pokemonWeightValue(after.weightPounds));
pushChange(changes, 'Types', namedListValue(before.types), namesFromIds(after.typeIds, typeNames));
pushChange(changes, 'Stats', pokemonStatsValue(before.stats), pokemonStatsValue(after.stats));
pushChange(changes, 'Ideal Habitat', before.environment.name, environmentNames.get(after.environmentId));
pushChange(changes, 'Specialities', namedListValue(before.skills), namesFromIds(after.skillIds, skillNames));
pushChange(changes, 'Favourites', namedListValue(before.favorite_things), namesFromIds(after.favoriteThingIds, favoriteThingNames));
@@ -853,6 +947,9 @@ function getEditHistory(entityType: string, entityId: number): Promise<EditHisto
function pokemonProjection(locale: string): string {
const pokemonName = localizedName('pokemon', 'p', locale);
const pokemonGenus = localizedField('pokemon', 'p.id', 'p.genus', 'genus', locale);
const pokemonDetails = localizedField('pokemon', 'p.id', 'p.details', 'details', locale);
const typeName = localizedName('pokemon-types', 'pt', locale);
const environmentName = localizedName('environments', 'e', locale);
const skillName = localizedName('skills', 's', locale);
const favoriteThingName = localizedName('favorite-things', 'ft', locale);
@@ -862,9 +959,31 @@ function pokemonProjection(locale: string): string {
p.id,
${pokemonName} AS name,
p.name AS "baseName",
${pokemonGenus} AS genus,
p.genus AS "baseGenus",
${pokemonDetails} AS details,
p.details AS "baseDetails",
p.height_inches AS "heightInches",
round((p.height_inches * 0.0254)::numeric, 2)::double precision AS "heightMeters",
p.weight_pounds AS "weightPounds",
round((p.weight_pounds * 0.45359237)::numeric, 2)::double precision AS "weightKg",
json_build_object(
'hp', p.hp,
'attack', p.attack,
'defense', p.defense,
'specialAttack', p.special_attack,
'specialDefense', p.special_defense,
'speed', p.speed
) AS stats,
${translationsSelect('pokemon', 'p.id')} AS translations,
${auditSelect('p', 'pokemon_created_user', 'pokemon_updated_user')},
json_build_object('id', e.id, 'name', ${environmentName}) AS environment,
COALESCE((
SELECT json_agg(json_build_object('id', pt.id, 'name', ${typeName}) ORDER BY ppt.slot_order)
FROM pokemon_pokemon_types ppt
JOIN pokemon_types pt ON pt.id = ppt.type_id
WHERE ppt.pokemon_id = p.id
), '[]'::json) AS types,
COALESCE((
SELECT json_agg(json_build_object('id', s.id, 'name', ${skillName}, 'hasItemDrop', s.has_item_drop) ORDER BY ${orderByEntity('s')})
FROM pokemon_skills ps
@@ -885,6 +1004,7 @@ function pokemonProjection(locale: string): string {
export async function getOptions(locale = defaultLocale) {
const [
pokemonTypes,
skills,
environments,
favoriteThings,
@@ -893,6 +1013,7 @@ export async function getOptions(locale = defaultLocale) {
acquisitionMethods,
maps
] = await Promise.all([
optionSelect('pokemon_types', 'pokemon-types', locale),
skillOptions(locale),
optionSelect('environments', 'environments', locale),
optionSelect('favorite_things', 'favorite-things', locale),
@@ -903,6 +1024,7 @@ export async function getOptions(locale = defaultLocale) {
]);
return {
pokemonTypes,
skills,
environments,
favoriteThings,
@@ -1344,11 +1466,19 @@ export async function getPokemon(id: number, locale = defaultLocale) {
}
function cleanPokemonPayload(payload: Record<string, unknown>): PokemonPayload {
const cleanTypeIds = cleanIds(payload.typeIds);
const typeIds = cleanTypeIds.slice(0, 2);
const skillIds = cleanIds(payload.skillIds);
const favoriteThingIds = cleanIds(payload.favoriteThingIds);
const selectedSkillIds = new Set(skillIds);
const skillItemDrops = new Map<string, SkillItemDrop>();
if (typeIds.length === 0) {
throw validationError('Choose at least 1 type');
}
if (cleanTypeIds.length > 2) {
throw validationError('Choose at most 2 types');
}
if (skillIds.length > 2) {
throw validationError('Choose at most 2 specialities');
}
@@ -1377,7 +1507,13 @@ function cleanPokemonPayload(payload: Record<string, unknown>): PokemonPayload {
return {
id: requirePositiveInteger(payload.id, 'Pokemon ID is required'),
name: cleanName(payload.name, 'Pokemon name is required'),
translations: cleanTranslations(payload.translations, ['name']),
genus: cleanOptionalText(payload.genus),
details: cleanOptionalText(payload.details),
heightInches: cleanNonNegativeNumber(payload.heightInches, 'Height must be a non-negative number'),
weightPounds: cleanNonNegativeNumber(payload.weightPounds, 'Weight must be a non-negative number'),
translations: cleanTranslations(payload.translations, ['name', 'details', 'genus']),
typeIds,
stats: cleanPokemonStats(payload.stats),
environmentId: requirePositiveInteger(payload.environmentId, 'Ideal Habitat is required'),
skillIds,
favoriteThingIds,
@@ -1387,9 +1523,18 @@ function cleanPokemonPayload(payload: Record<string, unknown>): PokemonPayload {
async function replacePokemonRelations(client: DbClient, pokemonId: number, payload: PokemonPayload): Promise<void> {
await client.query('DELETE FROM pokemon_skill_item_drops 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_favorite_things WHERE pokemon_id = $1', [pokemonId]);
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)', [
pokemonId,
typeId,
index + 1
]);
}
for (const skillId of payload.skillIds) {
await client.query('INSERT INTO pokemon_skills (pokemon_id, skill_id) VALUES ($1, $2)', [pokemonId, skillId]);
}
@@ -1428,13 +1573,46 @@ export async function createPokemon(payload: Record<string, unknown>, userId: nu
const sortOrder = await nextSortOrder(client, 'pokemon');
await client.query(
`
INSERT INTO pokemon (id, name, environment_id, sort_order, created_by_user_id, updated_by_user_id)
VALUES ($1, $2, $3, $4, $5, $5)
INSERT INTO pokemon (
id,
name,
genus,
details,
height_inches,
weight_pounds,
environment_id,
hp,
attack,
defense,
special_attack,
special_defense,
speed,
sort_order,
created_by_user_id,
updated_by_user_id
)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $15)
`,
[cleanPayload.id, cleanPayload.name, cleanPayload.environmentId, sortOrder, userId]
[
cleanPayload.id,
cleanPayload.name,
cleanPayload.genus,
cleanPayload.details,
cleanPayload.heightInches,
cleanPayload.weightPounds,
cleanPayload.environmentId,
cleanPayload.stats.hp,
cleanPayload.stats.attack,
cleanPayload.stats.defense,
cleanPayload.stats.specialAttack,
cleanPayload.stats.specialDefense,
cleanPayload.stats.speed,
sortOrder,
userId
]
);
await replacePokemonRelations(client, cleanPayload.id, cleanPayload);
await replaceEntityTranslations(client, 'pokemon', cleanPayload.id, cleanPayload.translations, ['name']);
await replaceEntityTranslations(client, 'pokemon', cleanPayload.id, cleanPayload.translations, ['name', 'details', 'genus']);
await recordEditLog(client, 'pokemon', cleanPayload.id, 'create', userId);
return cleanPayload.id;
});
@@ -1449,16 +1627,45 @@ export async function updatePokemon(id: number, payload: Record<string, unknown>
const result = await client.query(
`
UPDATE pokemon
SET name = $1, environment_id = $2, updated_by_user_id = $3, updated_at = now()
WHERE id = $4
SET
name = $1,
genus = $2,
details = $3,
height_inches = $4,
weight_pounds = $5,
environment_id = $6,
hp = $7,
attack = $8,
defense = $9,
special_attack = $10,
special_defense = $11,
speed = $12,
updated_by_user_id = $13,
updated_at = now()
WHERE id = $14
`,
[cleanPayload.name, cleanPayload.environmentId, userId, id]
[
cleanPayload.name,
cleanPayload.genus,
cleanPayload.details,
cleanPayload.heightInches,
cleanPayload.weightPounds,
cleanPayload.environmentId,
cleanPayload.stats.hp,
cleanPayload.stats.attack,
cleanPayload.stats.defense,
cleanPayload.stats.specialAttack,
cleanPayload.stats.specialDefense,
cleanPayload.stats.speed,
userId,
id
]
);
if (result.rowCount === 0) {
return false;
}
await replacePokemonRelations(client, id, cleanPayload);
await replaceEntityTranslations(client, 'pokemon', id, cleanPayload.translations, ['name']);
await replaceEntityTranslations(client, 'pokemon', id, cleanPayload.translations, ['name', 'details', 'genus']);
const changes = before ? await pokemonEditChanges(client, before as unknown as PokemonChangeSource, cleanPayload) : [];
await recordEditLog(client, 'pokemon', id, 'update', userId, changes);
return true;

View File

@@ -12,6 +12,17 @@ const changeLabelKeys: Record<string, string> = {
Name: 'common.name',
名字: 'common.name',
名称: 'common.name',
Genus: 'pages.pokemon.genus',
Details: 'pages.pokemon.details',
介绍: 'pages.pokemon.details',
Height: 'pages.pokemon.height',
身高: 'pages.pokemon.height',
Weight: 'pages.pokemon.weight',
体重: 'pages.pokemon.weight',
Types: 'pages.pokemon.types',
属性: 'pages.pokemon.types',
Stats: 'pages.pokemon.statsTitle',
六维: 'pages.pokemon.statsTitle',
'Ideal Habitat': 'pages.pokemon.environment',
'Favorite environment': 'pages.pokemon.environment',
喜欢的环境: 'pages.pokemon.environment',

View File

@@ -0,0 +1,50 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n';
import type { PokemonStats } from '../services/api';
const props = defineProps<{
idPrefix: string;
modelValue: PokemonStats;
}>();
const emit = defineEmits<{
'update:modelValue': [value: PokemonStats];
}>();
const { t } = useI18n();
const statRows: Array<{ key: keyof PokemonStats; labelKey: string }> = [
{ key: 'hp', labelKey: 'pages.pokemon.stats.hp' },
{ key: 'attack', labelKey: 'pages.pokemon.stats.attack' },
{ key: 'defense', labelKey: 'pages.pokemon.stats.defense' },
{ key: 'specialAttack', labelKey: 'pages.pokemon.stats.specialAttack' },
{ key: 'specialDefense', labelKey: 'pages.pokemon.stats.specialDefense' },
{ key: 'speed', labelKey: 'pages.pokemon.stats.speed' }
];
function updateStat(key: keyof PokemonStats, event: Event) {
const input = event.target as HTMLInputElement;
const value = Number(input.value);
emit('update:modelValue', {
...props.modelValue,
[key]: Number.isInteger(value) && value >= 0 ? value : 0
});
}
</script>
<template>
<div class="pokemon-stats-fields">
<div v-for="stat in statRows" :key="stat.key" class="field">
<label :for="`${idPrefix}-${stat.key}`">{{ t(stat.labelKey) }}</label>
<input
:id="`${idPrefix}-${stat.key}`"
:value="modelValue[stat.key]"
min="0"
step="1"
type="number"
inputmode="numeric"
@input="updateStat(stat.key, $event)"
/>
</div>
</div>
</template>

View File

@@ -0,0 +1,33 @@
<script setup lang="ts">
import { useI18n } from 'vue-i18n';
import ProgressBar from './ProgressBar.vue';
import type { PokemonStats } from '../services/api';
defineProps<{
stats: PokemonStats;
}>();
const { t } = useI18n();
const statMax = 150;
const statRows: Array<{ key: keyof PokemonStats; labelKey: string; color: string }> = [
{ key: 'hp', labelKey: 'pages.pokemon.stats.hp', color: 'var(--success)' },
{ key: 'attack', labelKey: 'pages.pokemon.stats.attack', color: 'var(--pokemon-red)' },
{ key: 'defense', labelKey: 'pages.pokemon.stats.defense', color: 'var(--pokemon-blue)' },
{ key: 'specialAttack', labelKey: 'pages.pokemon.stats.specialAttack', color: 'var(--type-psychic)' },
{ key: 'specialDefense', labelKey: 'pages.pokemon.stats.specialDefense', color: 'var(--type-water)' },
{ key: 'speed', labelKey: 'pages.pokemon.stats.speed', color: 'var(--pokemon-yellow)' }
];
</script>
<template>
<div class="pokemon-stats-panel">
<ProgressBar
v-for="stat in statRows"
:key="stat.key"
:label="t(stat.labelKey)"
:value="stats[stat.key]"
:max="statMax"
:color="stat.color"
/>
</div>
</template>

View File

@@ -0,0 +1,33 @@
<script setup lang="ts">
import { computed } from 'vue';
const props = withDefaults(
defineProps<{
label: string;
value: number;
max?: number;
color?: string;
}>(),
{
max: 100,
color: 'var(--pokemon-blue)'
}
);
const safeMax = computed(() => (Number.isFinite(props.max) && props.max > 0 ? props.max : 100));
const safeValue = computed(() => (Number.isFinite(props.value) && props.value > 0 ? props.value : 0));
const percentage = computed(() => Math.min(100, Math.round((safeValue.value / safeMax.value) * 100)));
const valueText = computed(() => `${safeValue.value} / ${safeMax.value}`);
</script>
<template>
<div class="progress" role="meter" :aria-label="label" :aria-valuenow="safeValue" aria-valuemin="0" :aria-valuemax="safeMax">
<div class="progress-label">
<span>{{ label }}</span>
<span>{{ valueText }}</span>
</div>
<div class="progress-track">
<span class="progress-fill" :style="{ width: `${percentage}%`, background: color }"></span>
</div>
</div>
</template>

View File

@@ -11,6 +11,8 @@ const props = defineProps<{
translations: TranslationMap;
languages: Language[];
required?: boolean;
multiline?: boolean;
rows?: number;
}>();
const emit = defineEmits<{
@@ -79,11 +81,20 @@ function updateField(language: Language, value: string) {
{{ t('common.fieldForLanguage', { field: label, language: currentLanguage.name }) }}
</label>
<input
v-if="!multiline"
:id="`${idPrefix}-${currentLanguage.code}`"
v-model="currentValue"
:placeholder="currentPlaceholder"
:required="currentRequired"
/>
<textarea
v-else
:id="`${idPrefix}-${currentLanguage.code}`"
v-model="currentValue"
:placeholder="currentPlaceholder"
:required="currentRequired"
:rows="rows ?? 4"
></textarea>
</div>
</div>
</template>

View File

@@ -87,13 +87,40 @@ const messages = {
subtitle: 'Search Pokemon and filter by specialities, ideal habitat, and favourites.',
detailKicker: 'Pokédex Detail',
editKicker: 'Pokédex Edit',
editSubtitle: 'Maintain Pokemon profile, specialities, and favourites.',
editSubtitle: 'Maintain Pokemon profile, details, types, stats, specialities, and favourites.',
newTitle: 'New Pokemon',
editTitle: 'Edit #{id} {name}',
loadingList: 'Loading Pokemon list',
loadingDetail: 'Loading Pokemon detail',
loadingEdit: 'Loading Pokemon editor',
environmentPrefix: 'Ideal Habitat: {name}',
details: 'Details',
genus: 'Genus',
height: 'Height',
heightInput: 'Height (in)',
heightImperial: 'ft / in',
heightMetric: 'm',
feet: 'ft',
inches: 'in',
meters: 'm',
weight: 'Weight',
weightInput: 'Weight (lb)',
pounds: 'lb',
kilograms: 'kg',
measurements: 'Height & Weight',
types: 'Types',
typeOne: 'Type 1',
typeTwo: 'Type 2',
typesAndStats: 'Types & Base stats',
statsTitle: 'Base stats',
stats: {
hp: 'HP',
attack: 'Attack',
defense: 'Defense',
specialAttack: 'Special Attack',
specialDefense: 'Special Defense',
speed: 'Speed'
},
environment: 'Ideal Habitat',
skills: 'Specialities',
skillMatchMode: 'Speciality match mode',
@@ -109,6 +136,7 @@ const messages = {
relatedItemCategory: 'Related item category',
habitats: 'Habitats',
namePlaceholder: 'Name',
searchTypes: 'Search types',
searchEnvironment: 'Search ideal habitats',
searchSkills: 'Search specialities',
searchFavoriteThings: 'Search favourites',
@@ -220,6 +248,7 @@ const messages = {
}
},
config: {
pokemonTypes: 'Pokemon Types',
skills: 'Specialities',
environments: 'Ideal Habitats',
favoriteThings: 'Favourites / tags',
@@ -341,13 +370,40 @@ const messages = {
subtitle: '搜索宝可梦,并按特长、环境、喜欢的东西筛选。',
detailKicker: 'Pokédex Detail',
editKicker: 'Pokédex Edit',
editSubtitle: '维护 Pokemon 基本资料、特长和喜欢的东西。',
editSubtitle: '维护 Pokemon 介绍、属性、六维、特长和喜欢的东西。',
newTitle: '新增 Pokemon',
editTitle: '编辑 #{id} {name}',
loadingList: '正在加载 Pokemon 列表',
loadingDetail: '正在加载 Pokemon 详情',
loadingEdit: '正在加载 Pokemon 编辑内容',
environmentPrefix: '喜欢的环境:{name}',
details: '介绍',
genus: '分类',
height: '身高',
heightInput: '身高in',
heightImperial: 'ft / in',
heightMetric: 'm',
feet: 'ft',
inches: 'in',
meters: 'm',
weight: '体重',
weightInput: '体重lb',
pounds: 'lb',
kilograms: 'kg',
measurements: '身高与体重',
types: '属性',
typeOne: '属性 1',
typeTwo: '属性 2',
typesAndStats: '属性与六维',
statsTitle: '六维',
stats: {
hp: 'HP',
attack: '攻击',
defense: '防御',
specialAttack: '特攻',
specialDefense: '特防',
speed: '速度'
},
environment: '喜欢的环境',
skills: '特长',
skillMatchMode: '特长匹配方式',
@@ -363,6 +419,7 @@ const messages = {
relatedItemCategory: '关联物品分类',
habitats: '栖息地',
namePlaceholder: '名字',
searchTypes: '搜索属性',
searchEnvironment: '搜索喜欢的环境',
searchSkills: '搜索特长',
searchFavoriteThings: '搜索喜欢的东西',
@@ -474,6 +531,7 @@ const messages = {
}
},
config: {
pokemonTypes: 'Pokemon 属性',
skills: '特长',
environments: '喜欢的环境',
favoriteThings: '喜欢的东西 / 标签',

View File

@@ -4,7 +4,7 @@ const apiBaseUrl = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:3001';
const authTokenKey = 'pokopia_auth_token';
const authChangeEvent = 'pokopia-auth-change';
export type TranslationField = 'name' | 'title';
export type TranslationField = 'name' | 'title' | 'details' | 'genus';
export type TranslationMap = Record<string, Partial<Record<TranslationField, string>>>;
export interface Language {
@@ -26,6 +26,15 @@ export interface Skill extends NamedEntity {
hasItemDrop: boolean;
}
export interface PokemonStats {
hp: number;
attack: number;
defense: number;
specialAttack: number;
specialDefense: number;
speed: number;
}
export interface UserSummary {
id: number;
displayName: string;
@@ -57,7 +66,17 @@ export interface Pokemon extends EditInfo {
id: number;
name: string;
baseName?: string;
genus: string;
baseGenus?: string;
details: string;
baseDetails?: string;
heightInches: number;
heightMeters: number;
weightPounds: number;
weightKg: number;
translations?: TranslationMap;
types: NamedEntity[];
stats: PokemonStats;
environment: NamedEntity;
skills: Skill[];
favorite_things: NamedEntity[];
@@ -161,6 +180,7 @@ export interface RecipeDetail extends Recipe {
}
export interface Options {
pokemonTypes: NamedEntity[];
skills: Skill[];
environments: NamedEntity[];
favoriteThings: NamedEntity[];
@@ -193,6 +213,7 @@ export interface AuthResponse {
}
export type ConfigType =
| 'pokemon-types'
| 'skills'
| 'environments'
| 'favorite-things'
@@ -204,7 +225,13 @@ export type ConfigType =
export interface PokemonPayload {
id: number;
name: string;
genus: string;
details: string;
heightInches: number;
weightPounds: number;
translations?: TranslationMap;
typeIds: number[];
stats: PokemonStats;
environmentId: number;
skillIds: number[];
favoriteThingIds: number[];

View File

@@ -6,6 +6,8 @@
--pokemon-blue-deep: #003a70;
--pokemon-red: #ee1515;
--pokemon-red-deep: #cc0000;
--type-water: #6390f0;
--type-psychic: #f95587;
--pokeball-black: #202124;
--pokeball-white: #f7f8fb;
--bg: #f2f5fa;
@@ -540,6 +542,7 @@ button:disabled,
.field input,
.field select,
.field textarea,
.tags-select__search {
width: 100%;
min-height: 44px;
@@ -555,12 +558,18 @@ button:disabled,
.field input:focus,
.field select:focus,
.field textarea:focus,
.tags-select__search:focus {
border-color: var(--pokemon-blue);
box-shadow: 0 0 0 4px rgba(42, 117, 187, 0.16);
outline: none;
}
.field textarea {
min-height: 112px;
resize: vertical;
}
.modal-backdrop {
position: fixed;
inset: 0;
@@ -658,6 +667,13 @@ button:disabled,
gap: 12px;
}
.pokemon-measurement-fields,
.pokemon-stats-fields {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(138px, 1fr));
gap: 12px;
}
.modal-footer {
border-top: 1px solid var(--line);
justify-content: flex-end;
@@ -1322,6 +1338,12 @@ button:disabled,
align-items: start;
}
.pokemon-detail-sidebar {
display: grid;
gap: 16px;
min-width: 0;
}
.habitat-detail-stack {
display: grid;
gap: 16px;
@@ -1649,6 +1671,195 @@ button:disabled,
justify-content: flex-end;
}
.detail-text {
margin: 0;
color: var(--ink-soft);
white-space: pre-wrap;
}
.pokemon-profile-grid {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(280px, 360px);
gap: 16px;
align-items: stretch;
}
.pokemon-profile-main,
.pokemon-profile-row {
display: grid;
gap: 16px;
min-width: 0;
}
.pokemon-profile-row {
grid-template-columns: minmax(0, 2fr) minmax(0, 1fr);
}
.pokemon-profile-card,
.pokemon-profile-stats {
gap: 12px;
min-width: 0;
}
.pokemon-profile-stats {
align-self: stretch;
}
.pokemon-types-card {
align-content: center;
justify-items: center;
}
.pokemon-genus {
margin: 0;
color: var(--ink);
font-size: 1rem;
font-weight: 900;
}
.pokemon-profile-divider {
height: 1px;
background: var(--line);
}
.pokemon-measurement-display {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 0;
align-items: stretch;
}
.pokemon-measurement-item {
display: grid;
justify-items: center;
align-content: center;
min-width: 0;
padding: 4px 18px;
}
.pokemon-measurement-item + .pokemon-measurement-item {
border-left: 1px solid var(--line);
}
.pokemon-measurement-stack {
width: 100%;
display: grid;
justify-items: center;
align-content: center;
gap: 7px;
text-align: center;
}
.pokemon-measurement-value {
color: var(--ink);
font-size: 1.14rem;
font-weight: 950;
line-height: 1.05;
font-variant-numeric: tabular-nums;
overflow-wrap: anywhere;
}
.pokemon-measurement-divider {
width: min(92px, 72%);
height: 1px;
background: var(--line);
}
.pokemon-measurement-label {
color: var(--muted);
font-size: 0.74rem;
font-weight: 900;
line-height: 1;
}
.pokemon-stats-panel {
display: grid;
gap: 12px;
}
.pokemon-profile-facts {
display: grid;
gap: 10px;
margin: 0;
}
.pokemon-profile-facts div {
display: grid;
gap: 2px;
}
.pokemon-profile-facts dt {
color: var(--muted);
font-size: 0.78rem;
font-weight: 850;
}
.pokemon-profile-facts dd {
margin: 0;
color: var(--ink);
font-weight: 800;
}
.pokemon-type-slots {
display: grid;
grid-template-columns: minmax(0, 1fr);
gap: 10px;
width: 100%;
align-content: center;
justify-items: center;
}
.pokemon-type-slots--single {
grid-template-columns: minmax(0, 1fr);
justify-items: center;
}
.pokemon-type-slot {
display: grid;
justify-items: center;
gap: 8px;
min-width: 0;
}
.progress {
display: grid;
gap: 6px;
min-width: 0;
}
.progress-label {
display: flex;
justify-content: space-between;
gap: 8px;
color: var(--muted);
font-size: 0.82rem;
font-weight: 850;
}
.progress-label span {
min-width: 0;
}
.progress-label span:last-child {
flex: 0 0 auto;
font-variant-numeric: tabular-nums;
}
.progress-track {
height: 12px;
overflow: hidden;
border: 1px solid var(--line);
border-radius: 999px;
background: var(--surface-soft);
}
.progress-fill {
display: block;
height: 100%;
border-radius: inherit;
background: var(--pokemon-blue);
}
.appearance-list li {
display: grid;
grid-template-columns: clamp(140px, 20%, 220px) minmax(0, 1fr);
@@ -2086,6 +2297,8 @@ button:disabled,
.detail-grid,
.detail-with-sidebar,
.pokemon-profile-grid,
.pokemon-profile-row,
.admin-layout {
grid-template-columns: 1fr;
}

View File

@@ -66,6 +66,7 @@ const tabs = computed<Array<{ key: AdminTab; label: string }>>(() => [
]);
const configTypes = computed<Array<{ key: ConfigType; label: string; supportsItemDrop?: boolean }>>(() => [
{ key: 'pokemon-types', label: t('config.pokemonTypes') },
{ key: 'skills', label: t('config.skills'), supportsItemDrop: true },
{ key: 'environments', label: t('config.environments') },
{ key: 'favorite-things', label: t('config.favoriteThings') },

View File

@@ -7,6 +7,7 @@ import DetailSection from '../components/DetailSection.vue';
import EditHistoryPanel from '../components/EditHistoryPanel.vue';
import EntityChips from '../components/EntityChips.vue';
import PageHeader from '../components/PageHeader.vue';
import PokemonStatsPanel from '../components/PokemonStatsPanel.vue';
import Skeleton from '../components/Skeleton.vue';
import Tabs, { type TabOption } from '../components/Tabs.vue';
import { iconBack, iconEdit } from '../icons';
@@ -122,6 +123,24 @@ const favoriteThingItems = computed(() => {
return items.filter((item) => String(item.category.id) === itemCategoryTab.value);
});
const typeSlotClass = computed(() => ({
'pokemon-type-slots--single': (pokemon.value?.types.length ?? 0) === 1
}));
function formatMetricMeasure(value: number): string {
return value.toFixed(2);
}
function formatPoundsMeasure(value: number): string {
return (Math.round(value * 10) / 10).toFixed(1);
}
function formatImperialHeight(inches: number): string {
const totalInches = Math.round(inches);
const feet = Math.floor(totalInches / 12);
const remainingInches = totalInches - feet * 12;
return `${feet}'${remainingInches}"`;
}
async function loadPokemonDetail() {
pokemon.value = await api.pokemonDetail(String(route.params.id));
@@ -223,6 +242,51 @@ watch(
<div class="detail-with-sidebar">
<div class="detail-grid detail-grid--stack">
<div class="pokemon-profile-grid">
<div class="pokemon-profile-main">
<section class="detail-section pokemon-profile-card" :aria-label="t('pages.pokemon.details')">
<p v-if="pokemon.genus" class="pokemon-genus">{{ pokemon.genus }}</p>
<div v-if="pokemon.genus && pokemon.details.trim()" class="pokemon-profile-divider"></div>
<p v-if="pokemon.details.trim()" class="detail-text">{{ pokemon.details }}</p>
<p v-if="!pokemon.genus && !pokemon.details.trim()" class="meta-line">{{ t('common.none') }}</p>
</section>
<div class="pokemon-profile-row">
<section class="detail-section pokemon-profile-card" :aria-label="t('pages.pokemon.measurements')">
<div class="pokemon-measurement-display">
<div class="pokemon-measurement-item" :title="`${formatImperialHeight(pokemon.heightInches)} / ${formatMetricMeasure(pokemon.heightMeters)} m`">
<div class="pokemon-measurement-stack">
<strong class="pokemon-measurement-value">{{ formatImperialHeight(pokemon.heightInches) }}</strong>
<span class="pokemon-measurement-divider" aria-hidden="true"></span>
<strong class="pokemon-measurement-value">{{ formatMetricMeasure(pokemon.heightMeters) }} m</strong>
<span class="pokemon-measurement-label">{{ t('pages.pokemon.height') }}</span>
</div>
</div>
<div class="pokemon-measurement-item" :title="`${formatPoundsMeasure(pokemon.weightPounds)} lbs / ${formatMetricMeasure(pokemon.weightKg)} kg`">
<div class="pokemon-measurement-stack">
<strong class="pokemon-measurement-value">{{ formatPoundsMeasure(pokemon.weightPounds) }} lbs</strong>
<span class="pokemon-measurement-divider" aria-hidden="true"></span>
<strong class="pokemon-measurement-value">{{ formatMetricMeasure(pokemon.weightKg) }} kg</strong>
<span class="pokemon-measurement-label">{{ t('pages.pokemon.weight') }}</span>
</div>
</div>
</div>
</section>
<section class="detail-section pokemon-profile-card pokemon-types-card" :aria-label="t('pages.pokemon.types')">
<div v-if="pokemon.types.length" class="pokemon-type-slots" :class="typeSlotClass">
<span v-for="type in pokemon.types.slice(0, 2)" :key="type.id" class="chip">{{ type.name }}</span>
</div>
<p v-else class="meta-line">{{ t('common.none') }}</p>
</section>
</div>
</div>
<DetailSection class="pokemon-profile-stats" :title="t('pages.pokemon.statsTitle')">
<PokemonStatsPanel :stats="pokemon.stats" />
</DetailSection>
</div>
<DetailSection :title="t('pages.pokemon.skills')">
<EntityChips :items="pokemon.skills" />
</DetailSection>
@@ -287,7 +351,9 @@ watch(
</DetailSection>
</div>
<EditHistoryPanel :entity="pokemon" :history="pokemon.editHistory" />
<aside class="pokemon-detail-sidebar">
<EditHistoryPanel :entity="pokemon" :history="pokemon.editHistory" />
</aside>
</div>
</section>

View File

@@ -4,12 +4,22 @@ import { computed, onMounted, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute, useRouter } from 'vue-router';
import Modal from '../components/Modal.vue';
import PokemonStatsFields from '../components/PokemonStatsFields.vue';
import Skeleton from '../components/Skeleton.vue';
import StatusMessage from '../components/StatusMessage.vue';
import TagsSelect from '../components/TagsSelect.vue';
import TranslationFields from '../components/TranslationFields.vue';
import { iconCancel, iconSave } from '../icons';
import { api, type ConfigType, type Language, type NamedEntity, type Options, type PokemonPayload, type TranslationMap } from '../services/api';
import {
api,
type ConfigType,
type Language,
type NamedEntity,
type Options,
type PokemonPayload,
type PokemonStats,
type TranslationMap
} from '../services/api';
type SkillItemDropForm = {
skillId: string;
@@ -26,10 +36,30 @@ const loading = ref(true);
const busy = ref(false);
const message = ref('');
const creatingSelect = ref('');
const heightUnit = ref<'imperial' | 'metric'>('imperial');
const weightUnit = ref<'imperial' | 'metric'>('imperial');
function defaultPokemonStats(): PokemonStats {
return {
hp: 0,
attack: 0,
defense: 0,
specialAttack: 0,
specialDefense: 0,
speed: 0
};
}
const pokemonForm = ref({
id: '',
name: '',
genus: '',
details: '',
heightInches: 0,
weightPounds: 0,
translations: {} as TranslationMap,
typeIds: [] as string[],
stats: defaultPokemonStats(),
environmentId: '',
skillIds: [] as string[],
favoriteThingIds: [] as string[],
@@ -47,11 +77,47 @@ const cancelTo = computed(() => (isEditing.value ? `/pokemon/${routeId.value}` :
const selectedSkillDropRows = computed(() =>
pokemonForm.value.skillItemDrops.filter((row) => pokemonForm.value.skillIds.includes(row.skillId) && skillSupportsItemDrop(row.skillId))
);
const totalHeightInchesValue = computed(() => Math.round(pokemonForm.value.heightInches));
const heightFeetValue = computed(() => Math.floor(totalHeightInchesValue.value / 12));
const heightInchesValue = computed(() => totalHeightInchesValue.value - heightFeetValue.value * 12);
const heightMetersValue = computed(() => roundMeasurement(pokemonForm.value.heightInches * 0.0254, 2));
const weightPoundsValue = computed(() => roundMeasurement(pokemonForm.value.weightPounds, 1));
const weightKgValue = computed(() => roundMeasurement(pokemonForm.value.weightPounds * 0.45359237, 2));
function toIds(values: string[]): number[] {
return values.map(Number).filter((item) => Number.isInteger(item) && item > 0);
}
function numericInputValue(event: Event): number {
const value = event.target instanceof HTMLInputElement ? Number(event.target.value) : 0;
return Number.isFinite(value) && value > 0 ? value : 0;
}
function roundMeasurement(value: number, precision: number): number {
const scale = 10 ** precision;
return Math.round(value * scale) / scale;
}
function updateHeightFeet(event: Event) {
pokemonForm.value.heightInches = Math.round(numericInputValue(event) * 12 + heightInchesValue.value);
}
function updateHeightInches(event: Event) {
pokemonForm.value.heightInches = Math.round(heightFeetValue.value * 12 + numericInputValue(event));
}
function updateHeightMeters(event: Event) {
pokemonForm.value.heightInches = roundMeasurement(numericInputValue(event) / 0.0254, 2);
}
function updateWeightPounds(event: Event) {
pokemonForm.value.weightPounds = roundMeasurement(numericInputValue(event), 1);
}
function updateWeightKg(event: Event) {
pokemonForm.value.weightPounds = roundMeasurement(numericInputValue(event) / 0.45359237, 1);
}
function errorText(error: unknown, fallback: string) {
return error instanceof Error && error.message ? error.message : fallback;
}
@@ -113,7 +179,13 @@ async function loadEditor() {
pokemonForm.value = {
id: String(pokemon.id),
name: pokemon.baseName ?? pokemon.name,
genus: pokemon.baseGenus ?? pokemon.genus,
details: pokemon.baseDetails ?? pokemon.details,
heightInches: pokemon.heightInches,
weightPounds: pokemon.weightPounds,
translations: pokemon.translations ?? {},
typeIds: pokemon.types.map((type) => String(type.id)),
stats: pokemon.stats,
environmentId: String(pokemon.environment.id),
skillIds: pokemon.skills.map((skill) => String(skill.id)),
favoriteThingIds: pokemon.favorite_things.map((thing) => String(thing.id)),
@@ -176,7 +248,13 @@ async function savePokemon() {
const payload: PokemonPayload = {
id: Number(isEditing.value ? routeId.value : pokemonForm.value.id),
name: pokemonNameForSave(),
genus: pokemonForm.value.genus,
details: pokemonForm.value.details,
heightInches: pokemonForm.value.heightInches,
weightPounds: pokemonForm.value.weightPounds,
translations: pokemonForm.value.translations,
typeIds: toIds(pokemonForm.value.typeIds.slice(0, 2)),
stats: pokemonForm.value.stats,
environmentId: Number(pokemonForm.value.environmentId),
skillIds: toIds(pokemonForm.value.skillIds.slice(0, 2)),
favoriteThingIds: toIds(pokemonForm.value.favoriteThingIds.slice(0, 6)),
@@ -220,6 +298,96 @@ watch(() => pokemonForm.value.skillIds.slice(), syncSkillItemDrops);
required
/>
<TranslationFields
id-prefix="pokemon-genus"
v-model:base-value="pokemonForm.genus"
v-model:translations="pokemonForm.translations"
field="genus"
:label="t('pages.pokemon.genus')"
:languages="languages"
/>
<TranslationFields
id-prefix="pokemon-details"
v-model:base-value="pokemonForm.details"
v-model:translations="pokemonForm.translations"
field="details"
:label="t('pages.pokemon.details')"
:languages="languages"
multiline
:rows="5"
/>
<div class="field">
<span id="pokemon-height-label" class="field-label">{{ t('pages.pokemon.height') }}</span>
<div class="segmented" aria-labelledby="pokemon-height-label">
<button :class="{ active: heightUnit === 'imperial' }" type="button" @click="heightUnit = 'imperial'">
{{ t('pages.pokemon.heightImperial') }}
</button>
<button :class="{ active: heightUnit === 'metric' }" type="button" @click="heightUnit = 'metric'">
{{ t('pages.pokemon.heightMetric') }}
</button>
</div>
<div v-if="heightUnit === 'imperial'" class="pokemon-measurement-fields">
<div class="field">
<label for="pokemon-height-feet">{{ t('pages.pokemon.feet') }}</label>
<input id="pokemon-height-feet" :value="heightFeetValue" min="0" step="1" type="number" inputmode="numeric" @input="updateHeightFeet" />
</div>
<div class="field">
<label for="pokemon-height-inches">{{ t('pages.pokemon.inches') }}</label>
<input id="pokemon-height-inches" :value="heightInchesValue" min="0" step="1" type="number" inputmode="numeric" @input="updateHeightInches" />
</div>
</div>
<div v-else class="field">
<label for="pokemon-height-meters">{{ t('pages.pokemon.meters') }}</label>
<input id="pokemon-height-meters" :value="heightMetersValue" min="0" step="0.01" type="number" inputmode="decimal" @input="updateHeightMeters" />
</div>
</div>
<div class="field">
<span id="pokemon-weight-label" class="field-label">{{ t('pages.pokemon.weight') }}</span>
<div class="segmented" aria-labelledby="pokemon-weight-label">
<button :class="{ active: weightUnit === 'imperial' }" type="button" @click="weightUnit = 'imperial'">
{{ t('pages.pokemon.pounds') }}
</button>
<button :class="{ active: weightUnit === 'metric' }" type="button" @click="weightUnit = 'metric'">
{{ t('pages.pokemon.kilograms') }}
</button>
</div>
<div v-if="weightUnit === 'imperial'" class="field">
<label for="pokemon-weight-pounds">{{ t('pages.pokemon.pounds') }}</label>
<input id="pokemon-weight-pounds" :value="weightPoundsValue" min="0" step="0.1" type="number" inputmode="decimal" @input="updateWeightPounds" />
</div>
<div v-else class="field">
<label for="pokemon-weight-kg">{{ t('pages.pokemon.kilograms') }}</label>
<input id="pokemon-weight-kg" :value="weightKgValue" min="0" step="0.01" type="number" inputmode="decimal" @input="updateWeightKg" />
</div>
</div>
<div class="field">
<label for="pokemon-types">{{ t('pages.pokemon.types') }}</label>
<TagsSelect
id="pokemon-types"
v-model="pokemonForm.typeIds"
:options="options.pokemonTypes"
:max="2"
allow-create
:creating="creatingSelect === 'pokemon-types'"
:placeholder="t('pages.pokemon.searchTypes')"
@create="createMultiOption('pokemon-types', 'pokemon-types', $event, pokemonForm.typeIds, 2)"
/>
</div>
<div class="field">
<span class="field-label">{{ t('pages.pokemon.statsTitle') }}</span>
<PokemonStatsFields id-prefix="pokemon-stats" v-model="pokemonForm.stats" />
</div>
<div class="field">
<label for="pokemon-environment">{{ t('pages.pokemon.environment') }}</label>
<TagsSelect

View File

@@ -145,6 +145,7 @@ watch(query, loadPokemon);
:to="`/pokemon/${item.id}`"
>
<EditMeta :entity="item" />
<EntityChips v-if="item.types.length" :items="item.types" />
<EntityChips :items="item.skills" />
<EntityChips :items="item.favorite_things" />
</EntityCard>