diff --git a/DESIGN.md b/DESIGN.md index 5cd3625..53a904d 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -407,7 +407,8 @@ Pokemon 可配置: -- 内部 ID:`id`,系统唯一,用于路由、外键和实体关联;普通 Pokemon 新建时优先与展示 ID 一致,活动 Pokemon 由系统分配唯一内部 ID +- 内部 ID:`id`,系统唯一,用于路由、外键和实体关联;从 CSV Fetch 创建的普通 Pokemon 使用官方 data Pokemon ID 作为内部 ID,活动 Pokemon 和未关联官方 data 的自定义 Pokemon 由系统分配唯一内部 ID +- 官方 data 身份:`data_id` 和 `data_identifier`,可为空;用于记录该 Pokemon 对应的 CSV 官方 Pokemon ID 与 identifier,不作为用户可编辑展示 ID - 展示 ID:`display_id`,详情页、列表卡片和选择器中显示为 `#ID` - 是否为活动物品:`is_event_item` - 名称 @@ -432,7 +433,7 @@ Pokemon 可配置: - 翻译 - 排序 -Pokemon 的展示 ID 在普通 Pokemon 和活动 Pokemon 之间可以重复,例如允许同时存在普通 `#1 妙蛙种子` 和活动 `#1 毽子草`。数据库只要求同一个 `display_id + is_event_item` 组合唯一;前端路由和实体关联必须继续使用内部 `id`,不能使用展示 ID 作为路由或外键。 +Pokemon 的展示 ID 在普通 Pokemon 和活动 Pokemon 之间可以重复,例如允许同时存在普通 `#1 妙蛙种子` 和活动 `#1 毽子草`。数据库只要求同一个 `display_id + is_event_item` 组合唯一;前端路由和实体关联必须继续使用内部 `id`,不能使用展示 ID 作为路由或外键。Fetch 得到的官方 data ID 必须与展示 ID 分开保存;例如 Zorua 的官方 data ID 为 `570` 时,用户把展示 ID 改成 `123` 后仍应通过 `/pokemon/570` 访问该 Pokemon,`/pokemon/123` 只代表内部 ID 为 `123` 的其他 Pokemon。 Pokemon 编辑表单使用标签页组织字段: @@ -442,8 +443,9 @@ Pokemon 编辑表单使用标签页组织字段: - Fetch 输入框提供 data 列表搜索,搜索范围包含 Pokemon ID、identifier、当前语言名称和默认语言名称;结果只展示 `#ID`、名称和 identifier。 - Fetch 搜索结果默认关闭,只在用户主动点击输入框或输入内容时展开;Escape、失焦 / 点击外部、选择结果后关闭。 - Fetch 搜索不使用防抖或节流;前端在每次新搜索时取消上一条搜索请求,并且只渲染最新请求结果。 - - Fetch 只填入 CSV 可提供的字段:官方 data ID、名称、Genus、Height、Weight、Types、六维和名称/Genus 翻译;不填入 Details、喜欢的环境、特长、特长掉落物品或喜欢的东西。 + - Fetch 只填入 CSV 可提供的字段:官方 data ID、官方 data identifier、名称、Genus、Height、Weight、Types、六维和名称/Genus 翻译;不填入 Details、喜欢的环境、特长、特长掉落物品或喜欢的东西。 - Fetch data 不要求官方 data ID 与 Pokopia 展示 ID 相同;若表单 ID 已有用户输入则保留该展示 ID,只有新建且 ID 为空时才用官方 data ID 作为初始展示 ID。 + - Fetch 后保存普通 Pokemon 时,官方 data ID 作为内部路由 ID;展示 ID 只保存到 `display_id`。 - Fetch 不直接创建或更新 Pokemon;用户仍需通过 Save 保存,保存时沿用现有编辑审计。 - Fetch 根据 `languages.code` 自动匹配 CSV 语言列:`en`、`ja`、`ko`、`fr`、`de`、`es`、`it` 使用同名列;`zh-CN` / `zh-SG` 等简体语言使用 `zh_hans`;`zh-TW` / `zh-HK` / `zh-MO` 使用 `zh_hant`。 - Fetch 会自动确保 canonical Pokemon Types 存在于 `pokemon_types`,Type ID 与 `data/localized_type_name.csv` 和 `frontend/public/types` 图标文件保持一致;用户不需要为 Fetch 手工创建 Type 配置。 diff --git a/backend/db/schema.sql b/backend/db/schema.sql index 19de181..1a720b1 100644 --- a/backend/db/schema.sql +++ b/backend/db/schema.sql @@ -712,6 +712,8 @@ CREATE TABLE IF NOT EXISTS pokemon_types ( CREATE TABLE IF NOT EXISTS pokemon ( id integer PRIMARY KEY, + data_id integer CHECK (data_id > 0), + data_identifier text NOT NULL DEFAULT '', display_id integer NOT NULL CHECK (display_id > 0), name text NOT NULL UNIQUE, is_event_item boolean NOT NULL DEFAULT false, @@ -738,6 +740,10 @@ CREATE TABLE IF NOT EXISTS pokemon ( updated_at timestamptz NOT NULL DEFAULT now() ); +ALTER TABLE pokemon + ADD COLUMN IF NOT EXISTS data_id integer CHECK (data_id > 0), + ADD COLUMN IF NOT EXISTS data_identifier text NOT NULL DEFAULT ''; + 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, diff --git a/backend/src/queries.ts b/backend/src/queries.ts index 6924a48..8278688 100644 --- a/backend/src/queries.ts +++ b/backend/src/queries.ts @@ -110,6 +110,8 @@ type PokemonImageOptionsResult = { }; type PokemonPayload = { + dataId: number | null; + dataIdentifier: string; displayId: number; isEventItem: boolean; name: string; @@ -897,28 +899,19 @@ async function nextSortOrder(client: DbClient, tableName: string): Promise { - 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; - } +async function nextPokemonInternalId( + client: DbClient, + dataId: number | null, + isEventItem: boolean +): Promise { + if (!isEventItem && dataId !== null) { + return dataId; } const result = await client.query<{ id: number }>( - 'SELECT COALESCE(MAX(id), 0) + 1 AS id FROM pokemon WHERE id <> $1', - [displayId] + 'SELECT COALESCE(MAX(id), 999999) + 1 AS id FROM pokemon WHERE id >= 1000000' ); - const nextId = result.rows[0]?.id ?? 1; - return nextId === displayId ? nextId + 1 : nextId; + return result.rows[0]?.id ?? 1000000; } async function reorderTableRows( @@ -2160,6 +2153,8 @@ function pokemonProjection(locale: string): string { return ` SELECT p.id, + p.data_id AS "dataId", + p.data_identifier AS "dataIdentifier", p.display_id AS "displayId", ${pokemonName} AS name, p.name AS "baseName", @@ -4684,6 +4679,8 @@ function cleanPokemonPayload(payload: Record): PokemonPayload { const displayId = requirePositiveInteger(payload.displayId, 'server.validation.pokemonIdRequired'); return { + dataId: optionalPositiveInteger(payload.dataId, 'server.validation.pokemonIdRequired'), + dataIdentifier: cleanOptionalText(payload.dataIdentifier), displayId, isEventItem: Boolean(payload.isEventItem), name: cleanName(payload.name, 'server.validation.pokemonNameRequired'), @@ -4702,6 +4699,21 @@ function cleanPokemonPayload(payload: Record): PokemonPayload { }; } +async function normalizePokemonDataIdentity(payload: PokemonPayload): Promise { + if (payload.dataId === null) { + payload.dataIdentifier = ''; + return; + } + + const data = await loadPokemonCsvData(); + const pokemonRow = data.pokemonByLookup.get(String(payload.dataId)); + if (!pokemonRow) { + throw validationError('server.validation.pokemonDataNotFound'); + } + + payload.dataIdentifier = csvText(pokemonRow, 'identifier'); +} + 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]); @@ -4749,14 +4761,17 @@ async function replacePokemonRelations(client: DbClient, pokemonId: number, payl export async function createPokemon(payload: Record, userId: number, locale = defaultLocale) { const cleanPayload = cleanPokemonPayload(payload); + await normalizePokemonDataIdentity(cleanPayload); const id = await withTransaction(async (client) => { - const pokemonId = await nextPokemonInternalId(client, cleanPayload.displayId, cleanPayload.isEventItem); + const pokemonId = await nextPokemonInternalId(client, cleanPayload.dataId, cleanPayload.isEventItem); const sortOrder = await nextSortOrder(client, 'pokemon'); await client.query( ` INSERT INTO pokemon ( id, + data_id, + data_identifier, display_id, name, is_event_item, @@ -4780,10 +4795,12 @@ export async function createPokemon(payload: Record, userId: nu created_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, $21, $22, $22) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $21, $22, $23, $24, $24) `, [ pokemonId, + cleanPayload.dataId, + cleanPayload.dataIdentifier, cleanPayload.displayId, cleanPayload.name, cleanPayload.isEventItem, @@ -4818,6 +4835,10 @@ export async function createPokemon(payload: Record, userId: nu export async function updatePokemon(id: number, payload: Record, userId: number, locale = defaultLocale) { const cleanPayload = cleanPokemonPayload(payload); + await normalizePokemonDataIdentity(cleanPayload); + if (!cleanPayload.isEventItem && cleanPayload.dataId !== null && cleanPayload.dataId !== id) { + throw validationError('server.validation.pokemonDataIdMismatch'); + } const before = await getPokemon(id, defaultLocale); const updated = await withTransaction(async (client) => { @@ -4825,30 +4846,34 @@ export async function updatePokemon(id: number, payload: Record ` UPDATE pokemon SET - display_id = $1, - name = $2, - is_event_item = $3, - genus = $4, - details = $5, - height_inches = $6, - weight_pounds = $7, - environment_id = $8, - hp = $9, - attack = $10, - defense = $11, - special_attack = $12, - special_defense = $13, - speed = $14, - image_path = $15, - image_style = $16, - image_version = $17, - image_variant = $18, - image_description = $19, - updated_by_user_id = $20, + data_id = $1, + data_identifier = $2, + display_id = $3, + name = $4, + is_event_item = $5, + genus = $6, + details = $7, + height_inches = $8, + weight_pounds = $9, + environment_id = $10, + hp = $11, + attack = $12, + defense = $13, + special_attack = $14, + special_defense = $15, + speed = $16, + image_path = $17, + image_style = $18, + image_version = $19, + image_variant = $20, + image_description = $21, + updated_by_user_id = $22, updated_at = now() - WHERE id = $21 + WHERE id = $23 `, [ + cleanPayload.dataId, + cleanPayload.dataIdentifier, cleanPayload.displayId, cleanPayload.name, cleanPayload.isEventItem, diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 9731e7c..f643767 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -151,6 +151,8 @@ export interface EditHistoryEntry { export interface Pokemon extends EditInfo { id: number; + dataId?: number | null; + dataIdentifier?: string; displayId: number; name: string; baseName?: string; @@ -534,6 +536,8 @@ export type ConfigType = | 'game-versions'; export interface PokemonPayload { + dataId?: number | null; + dataIdentifier?: string; displayId: number; isEventItem: boolean; name: string; diff --git a/frontend/src/views/PokemonEdit.vue b/frontend/src/views/PokemonEdit.vue index a0bcdcd..8d00a74 100644 --- a/frontend/src/views/PokemonEdit.vue +++ b/frontend/src/views/PokemonEdit.vue @@ -79,6 +79,8 @@ function defaultPokemonStats(): PokemonStats { } const pokemonForm = ref({ + dataId: null as number | null, + dataIdentifier: '', id: '', isEventItem: false, name: '', @@ -257,8 +259,22 @@ function mergeFetchedTranslations(fetchedTranslations: TranslationMap | undefine } function applyFetchedPokemon(fetchedPokemon: PokemonFetchResult): boolean { + const routePokemonId = Number(routeId.value); + if ( + isEditing.value && + !pokemonForm.value.isEventItem && + Number.isInteger(routePokemonId) && + routePokemonId > 0 && + fetchedPokemon.id !== routePokemonId + ) { + message.value = t('pages.pokemon.fetchIdMismatch', { id: fetchedPokemon.id }); + return false; + } + pokemonForm.value = { ...pokemonForm.value, + dataId: fetchedPokemon.id, + dataIdentifier: fetchedPokemon.identifier, id: pokemonForm.value.id.trim() === '' ? String(fetchedPokemon.id) : pokemonForm.value.id, name: fetchedPokemon.name, genus: fetchedPokemon.genus, @@ -295,6 +311,8 @@ async function loadEditor() { if (isEditing.value) { const pokemon = await api.pokemonDetail(routeId.value); pokemonForm.value = { + dataId: pokemon.dataId ?? null, + dataIdentifier: pokemon.dataIdentifier ?? '', id: String(pokemon.displayId), isEventItem: pokemon.isEventItem, name: pokemon.baseName ?? pokemon.name, @@ -678,6 +696,8 @@ async function savePokemon() { try { const payload: PokemonPayload = { + dataId: pokemonForm.value.dataId, + dataIdentifier: pokemonForm.value.dataIdentifier, displayId: pokemonIdForSave(), isEventItem: pokemonForm.value.isEventItem, name: pokemonNameForSave(), diff --git a/system-wordings.ts b/system-wordings.ts index 2a3ecf6..f5ac107 100644 --- a/system-wordings.ts +++ b/system-wordings.ts @@ -1054,6 +1054,7 @@ export const systemWordingMessages = { pokemonIdentifierRequired: 'Pokemon identifier is required', pokemonTypeDataUnavailable: 'Pokemon type data is unavailable', pokemonDataNotFound: 'Pokemon data was not found', + pokemonDataIdMismatch: 'Pokemon data ID does not match this Pokemon', pokemonImagePathInvalid: 'Pokemon image path is invalid', imagePathInvalid: 'Image path is invalid', imageUploadRequired: 'Please select an image', @@ -2167,6 +2168,7 @@ export const systemWordingMessages = { pokemonIdentifierRequired: '请输入 Pokemon 标识', pokemonTypeDataUnavailable: 'Pokemon 属性数据不可用', pokemonDataNotFound: '未找到 Pokemon 数据', + pokemonDataIdMismatch: 'Pokemon 数据 ID 与当前 Pokemon 不一致', pokemonImagePathInvalid: 'Pokemon 图片路径不合法', imagePathInvalid: '图片路径不合法', imageUploadRequired: '请选择图片',