diff --git a/DESIGN.md b/DESIGN.md index cbb22c7..00d5e94 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -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 展示 ## 物品 diff --git a/backend/db/schema.sql b/backend/db/schema.sql index cb22a4e..6f28326 100644 --- a/backend/db/schema.sql +++ b/backend/db/schema.sql @@ -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); diff --git a/backend/src/queries.ts b/backend/src/queries.ts index 6dd6542..6010290 100644 --- a/backend/src/queries.ts +++ b/backend/src/queries.ts @@ -8,10 +8,11 @@ type QueryParams = Record; type DbClient = PoolClient; -type TranslationField = 'name' | 'title'; +type TranslationField = 'name' | 'title' | 'details' | 'genus'; type TranslationInput = Record>>; 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 = { + '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) : {}; + + 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 { 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): 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(); + 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): 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): PokemonPayload { async function replacePokemonRelations(client: DbClient, pokemonId: number, payload: PokemonPayload): Promise { 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, 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 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; diff --git a/frontend/src/components/EditHistoryPanel.vue b/frontend/src/components/EditHistoryPanel.vue index 66fa3db..9b1c464 100644 --- a/frontend/src/components/EditHistoryPanel.vue +++ b/frontend/src/components/EditHistoryPanel.vue @@ -12,6 +12,17 @@ const changeLabelKeys: Record = { 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', diff --git a/frontend/src/components/PokemonStatsFields.vue b/frontend/src/components/PokemonStatsFields.vue new file mode 100644 index 0000000..1632c86 --- /dev/null +++ b/frontend/src/components/PokemonStatsFields.vue @@ -0,0 +1,50 @@ + + + diff --git a/frontend/src/components/PokemonStatsPanel.vue b/frontend/src/components/PokemonStatsPanel.vue new file mode 100644 index 0000000..c0ea932 --- /dev/null +++ b/frontend/src/components/PokemonStatsPanel.vue @@ -0,0 +1,33 @@ + + + diff --git a/frontend/src/components/ProgressBar.vue b/frontend/src/components/ProgressBar.vue new file mode 100644 index 0000000..9c0cf6c --- /dev/null +++ b/frontend/src/components/ProgressBar.vue @@ -0,0 +1,33 @@ + + + diff --git a/frontend/src/components/TranslationFields.vue b/frontend/src/components/TranslationFields.vue index 515cd96..fe6a3e8 100644 --- a/frontend/src/components/TranslationFields.vue +++ b/frontend/src/components/TranslationFields.vue @@ -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 }) }} + diff --git a/frontend/src/i18n.ts b/frontend/src/i18n.ts index ffef0ad..9b87a38 100644 --- a/frontend/src/i18n.ts +++ b/frontend/src/i18n.ts @@ -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: '喜欢的东西 / 标签', diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 1501068..3c87009 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -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>>; 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[]; diff --git a/frontend/src/styles/main.css b/frontend/src/styles/main.css index b9ee3a8..0a49114 100644 --- a/frontend/src/styles/main.css +++ b/frontend/src/styles/main.css @@ -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; } diff --git a/frontend/src/views/AdminView.vue b/frontend/src/views/AdminView.vue index babddd8..0ab1141 100644 --- a/frontend/src/views/AdminView.vue +++ b/frontend/src/views/AdminView.vue @@ -66,6 +66,7 @@ const tabs = computed>(() => [ ]); const configTypes = computed>(() => [ + { 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') }, diff --git a/frontend/src/views/PokemonDetail.vue b/frontend/src/views/PokemonDetail.vue index 406a43f..f6aae41 100644 --- a/frontend/src/views/PokemonDetail.vue +++ b/frontend/src/views/PokemonDetail.vue @@ -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(
+
+
+
+

{{ pokemon.genus }}

+
+

{{ pokemon.details }}

+

{{ t('common.none') }}

+
+ +
+
+
+
+
+ {{ formatImperialHeight(pokemon.heightInches) }} + + {{ formatMetricMeasure(pokemon.heightMeters) }} m + {{ t('pages.pokemon.height') }} +
+
+
+
+ {{ formatPoundsMeasure(pokemon.weightPounds) }} lbs + + {{ formatMetricMeasure(pokemon.weightKg) }} kg + {{ t('pages.pokemon.weight') }} +
+
+
+
+ +
+
+ {{ type.name }} +
+

{{ t('common.none') }}

+
+
+
+ + + + +
+ @@ -287,7 +351,9 @@ watch(
- +
diff --git a/frontend/src/views/PokemonEdit.vue b/frontend/src/views/PokemonEdit.vue index 78f9ea0..426a39f 100644 --- a/frontend/src/views/PokemonEdit.vue +++ b/frontend/src/views/PokemonEdit.vue @@ -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 /> + + + + +
+ {{ t('pages.pokemon.height') }} +
+ + +
+ +
+
+ + +
+ +
+ + +
+
+ +
+ + +
+
+ +
+ {{ t('pages.pokemon.weight') }} +
+ + +
+ +
+ + +
+ +
+ + +
+
+ +
+ + +
+ +
+ {{ t('pages.pokemon.statsTitle') }} + +
+
+