diff --git a/DESIGN.md b/DESIGN.md index ea936ea..acb9da2 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -232,19 +232,28 @@ Pokemon 可配置: Pokemon 编辑表单使用标签页组织字段: - 编辑表单提供 Fetch data 功能: - - 已验证用户可输入 data identifier 或 Pokemon ID,从仓库 `data/` CSV 查询基础资料并填入当前表单。 + - 已验证用户可输入 data identifier 或 Pokemon ID,从同一个搜索输入查询基础资料或图片候选。 + - Fetch data 从仓库 `data/` CSV 查询基础资料并填入当前表单。 - Fetch 输入框提供 data 列表搜索,搜索范围包含 Pokemon ID、identifier、当前语言名称和默认语言名称;结果只展示 `#ID`、名称和 identifier。 + - Fetch 搜索结果默认关闭,只在用户主动点击输入框或输入内容时展开;Escape、失焦 / 点击外部、选择结果后关闭。 - Fetch 搜索不使用防抖或节流;前端在每次新搜索时取消上一条搜索请求,并且只渲染最新请求结果。 - Fetch 只填入 CSV 可提供的字段:ID、名称、Genus、Height、Weight、Types、六维和名称/Genus 翻译;不填入 Details、喜欢的环境、特长、特长掉落物品或喜欢的东西。 - 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 配置。 - Type 展示使用 `frontend/public/types/small/{typeId}.png` 图标并保留文字名称。 +- 编辑表单提供 Pokemon 图片选择功能: + - 已验证用户通过 Fetch data 的同一个 data identifier / Pokemon ID 输入框,从 `https://pokesprite.tootaio.com/sprites/` 静态图片树查询对应 Pokemon 的可用图片候选。 + - 图片候选只使用 `/sprites/pokemon/...` 相对路径,后端按固定资源族生成候选并用 `HEAD` 校验存在性;不保存任意外部 URL。 + - 图片选择不直接创建或更新 Pokemon;用户仍需通过 Save 保存,保存时沿用现有编辑审计。 + - 图片选择界面使用 Pokédex 风格:上方显示当前选择的大图,大图下方显示版本、状态和描述,再下方以缩略图网格展示同一 Pokemon 的不同风格 / 版本 / 状态。 + - Pokemon 保存显示图片的相对路径、风格、版本、状态和描述;API 对外返回可直接展示的图片 URL,但不暴露内部校验状态。 - 基础标签页: - 第一行:ID、名称 - 第二行:喜欢的环境、特长 - 第三行:喜欢的东西 - 特长掉落物品随已选择且支持掉落物的特长显示 + - Pokemon 图片选择区 - Advance 标签页: - 第一行:Genus - 第二行:Details @@ -263,10 +272,12 @@ Pokemon 列表功能: - 满足任意条件 - 满足全部条件 - 按自定义排序展示 +- Pokemon 卡片在已配置图片时展示所选图片缩略图;未配置图片时保留默认 Poké Ball 标记。 Pokemon 详情页展示: - 基本信息 +- 已配置图片时,详情主内容顶部展示 Pokédex 风格图片区,包含大图和图片版本说明;未配置图片时不显示图片区。 - 主内容顶部按以下布局展示: - 左上:Genus & Details;无区块标题;如有 Genus,先展示 Genus,再以分割线连接 Details 内容 - 左下:Height / Weight 与 Types 按 2:1 比例并排;Height / Weight 无区块标题,在 Dimension 区内左右并排展示并以中间分割线隔开,每组按英制、分割线、公制、标签上下排列;Types 不显示 Type 1 / Type 2 文案,上下布局并居中展示 @@ -530,6 +541,7 @@ API 暴露边界: - Pokemon、栖息地、物品、材料单的创建、更新、删除。 - `GET /api/pokemon/fetch-options`:按搜索词返回 Pokemon CSV data 搜索结果;需要已验证用户;只返回 `id`、`identifier`、`name`。 - `POST /api/pokemon/fetch`:按 data identifier 或 Pokemon ID 查询 CSV 资料并填充 Pokemon 编辑表单;需要已验证用户;不直接保存 Pokemon。 +- `POST /api/pokemon/image-options`:按 data identifier 或 Pokemon ID 查询 pokesprite 可用图片候选;需要已验证用户;只返回 `id`、`identifier` 和图片候选列表。 - Life Post 的创建,以及作者本人对 Life Post 的更新、删除。 - `POST /api/life-posts` - `PUT /api/life-posts/:id` diff --git a/backend/db/schema.sql b/backend/db/schema.sql index 70a8b01..b6c4949 100644 --- a/backend/db/schema.sql +++ b/backend/db/schema.sql @@ -263,6 +263,11 @@ CREATE TABLE IF NOT EXISTS pokemon ( 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), + image_path text NOT NULL DEFAULT '', + image_style text NOT NULL DEFAULT '', + image_version text NOT NULL DEFAULT '', + image_variant text NOT NULL DEFAULT '', + image_description text NOT NULL DEFAULT '', sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0) ); @@ -462,6 +467,11 @@ ALTER TABLE pokemon ADD COLUMN IF NOT EXISTS defense integer NOT NULL DEFAULT 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 pokemon ADD COLUMN IF NOT EXISTS image_path text NOT NULL DEFAULT ''; +ALTER TABLE pokemon ADD COLUMN IF NOT EXISTS image_style text NOT NULL DEFAULT ''; +ALTER TABLE pokemon ADD COLUMN IF NOT EXISTS image_version text NOT NULL DEFAULT ''; +ALTER TABLE pokemon ADD COLUMN IF NOT EXISTS image_variant text NOT NULL DEFAULT ''; +ALTER TABLE pokemon ADD COLUMN IF NOT EXISTS image_description text NOT NULL DEFAULT ''; ALTER TABLE life_tags ADD COLUMN IF NOT EXISTS created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL; ALTER TABLE life_tags ADD COLUMN IF NOT EXISTS updated_by_user_id integer REFERENCES users(id) ON DELETE SET NULL; diff --git a/backend/src/queries.ts b/backend/src/queries.ts index f221e53..f5bfd04 100644 --- a/backend/src/queries.ts +++ b/backend/src/queries.ts @@ -70,6 +70,23 @@ type PokemonStats = { speed: number; }; +type PokemonImage = { + path: string; + url: string; + style: string; + version: string; + variant: string; + description: string; +}; + +type PokemonImageCandidate = Omit; + +type PokemonImageOptionsResult = { + id: number; + identifier: string; + images: PokemonImage[]; +}; + type PokemonPayload = { id: number; name: string; @@ -84,6 +101,7 @@ type PokemonPayload = { skillIds: number[]; favoriteThingIds: number[]; skillItemDrops: SkillItemDrop[]; + image: PokemonImage | null; }; type PokemonFetchResult = { @@ -255,6 +273,7 @@ type PokemonChangeSource = { details: string; heightInches: number; weightPounds: number; + image: PokemonImage | null; types: Array<{ name: string }>; stats: PokemonStats; environment: { name: string }; @@ -289,6 +308,8 @@ const defaultLifePostLimit = 20; const maxLifePostLimit = 50; const lifeReactionTypes = ['like', 'helpful', 'fun', 'thanks'] as const; const pokemonTypeIconIds = new Set(Array.from({ length: 19 }, (_value, index) => index + 1)); +const pokemonSpriteBaseUrl = 'https://pokesprite.tootaio.com'; +const pokemonSpriteRequestTimeoutMs = 2500; const pokemonStatLabels: Array<{ key: keyof PokemonStats; label: string }> = [ { key: 'hp', label: 'HP' }, { key: 'attack', label: 'Attack' }, @@ -1013,6 +1034,231 @@ function defaultCsvText(row: CsvRow, languages: LanguagePayload[], fallback: str return localizedCsvText(row, defaultCode) || localizedCsvText(row, defaultLocale) || fallback; } +function pokemonSpriteUrl(path: string): string { + return `${pokemonSpriteBaseUrl}${path}`; +} + +function pokemonImageWithUrl(candidate: PokemonImageCandidate): PokemonImage { + return { ...candidate, url: pokemonSpriteUrl(candidate.path) }; +} + +function pokemonImageCandidates(id: number): PokemonImageCandidate[] { + return [ + { + path: `/sprites/pokemon/other/official-artwork/${id}.png`, + style: 'Official artwork', + version: 'Official artwork', + variant: 'Default', + description: 'Large official artwork' + }, + { + path: `/sprites/pokemon/other/official-artwork/shiny/${id}.png`, + style: 'Official artwork', + version: 'Official artwork', + variant: 'Shiny', + description: 'Large shiny official artwork' + }, + { + path: `/sprites/pokemon/other/home/${id}.png`, + style: 'Pokemon HOME', + version: 'HOME', + variant: 'Default', + description: 'Modern HOME render' + }, + { + path: `/sprites/pokemon/other/home/shiny/${id}.png`, + style: 'Pokemon HOME', + version: 'HOME', + variant: 'Shiny', + description: 'Modern shiny HOME render' + }, + { + path: `/sprites/pokemon/other/home/female/${id}.png`, + style: 'Pokemon HOME', + version: 'HOME', + variant: 'Female', + description: 'Modern female HOME render' + }, + { + path: `/sprites/pokemon/other/home/shiny/female/${id}.png`, + style: 'Pokemon HOME', + version: 'HOME', + variant: 'Shiny female', + description: 'Modern shiny female HOME render' + }, + { + path: `/sprites/pokemon/other/dream-world/${id}.svg`, + style: 'Dream World', + version: 'Dream World', + variant: 'Default', + description: 'Dream World SVG artwork' + }, + { + path: `/sprites/pokemon/other/dream-world/female/${id}.svg`, + style: 'Dream World', + version: 'Dream World', + variant: 'Female', + description: 'Dream World female SVG artwork' + }, + { + path: `/sprites/pokemon/other/showdown/${id}.gif`, + style: 'Pokemon Showdown', + version: 'Showdown', + variant: 'Front animated', + description: 'Animated front battle sprite' + }, + { + path: `/sprites/pokemon/other/showdown/shiny/${id}.gif`, + style: 'Pokemon Showdown', + version: 'Showdown', + variant: 'Shiny front animated', + description: 'Animated shiny front battle sprite' + }, + { + path: `/sprites/pokemon/other/showdown/female/${id}.gif`, + style: 'Pokemon Showdown', + version: 'Showdown', + variant: 'Female front animated', + description: 'Animated female front battle sprite' + }, + { + path: `/sprites/pokemon/other/showdown/back/${id}.gif`, + style: 'Pokemon Showdown', + version: 'Showdown', + variant: 'Back animated', + description: 'Animated back battle sprite' + }, + { + path: `/sprites/pokemon/${id}.png`, + style: 'Default sprite', + version: 'PokeAPI', + variant: 'Front', + description: 'Compact front sprite' + }, + { + path: `/sprites/pokemon/shiny/${id}.png`, + style: 'Default sprite', + version: 'PokeAPI', + variant: 'Shiny front', + description: 'Compact shiny front sprite' + }, + { + path: `/sprites/pokemon/female/${id}.png`, + style: 'Default sprite', + version: 'PokeAPI', + variant: 'Female front', + description: 'Compact female front sprite' + }, + { + path: `/sprites/pokemon/back/${id}.png`, + style: 'Default sprite', + version: 'PokeAPI', + variant: 'Back', + description: 'Compact back sprite' + }, + { + path: `/sprites/pokemon/back/shiny/${id}.png`, + style: 'Default sprite', + version: 'PokeAPI', + variant: 'Shiny back', + description: 'Compact shiny back sprite' + }, + { + path: `/sprites/pokemon/versions/generation-v/black-white/animated/${id}.gif`, + style: 'Game version', + version: 'Black / White', + variant: 'Animated front', + description: 'Generation V animated sprite' + }, + { + path: `/sprites/pokemon/versions/generation-v/black-white/animated/shiny/${id}.gif`, + style: 'Game version', + version: 'Black / White', + variant: 'Animated shiny', + description: 'Generation V animated shiny sprite' + }, + { + path: `/sprites/pokemon/versions/generation-v/black-white/${id}.png`, + style: 'Game version', + version: 'Black / White', + variant: 'Front', + description: 'Generation V front sprite' + }, + { + path: `/sprites/pokemon/versions/generation-vi/x-y/${id}.png`, + style: 'Game version', + version: 'X / Y', + variant: 'Front', + description: 'Generation VI front sprite' + }, + { + path: `/sprites/pokemon/versions/generation-vii/ultra-sun-ultra-moon/${id}.png`, + style: 'Game version', + version: 'Ultra Sun / Ultra Moon', + variant: 'Front', + description: 'Generation VII front sprite' + }, + { + path: `/sprites/pokemon/versions/generation-ix/scarlet-violet/${id}.png`, + style: 'Game version', + version: 'Scarlet / Violet', + variant: 'Front', + description: 'Generation IX front sprite' + }, + { + path: `/sprites/pokemon/versions/generation-iii/emerald/${id}.png`, + style: 'Game version', + version: 'Emerald', + variant: 'Front', + description: 'Generation III front sprite' + }, + { + path: `/sprites/pokemon/versions/generation-i/red-blue/${id}.png`, + style: 'Game version', + version: 'Red / Blue', + variant: 'Front', + description: 'Generation I front sprite' + } + ]; +} + +function pokemonImageLabel(image: PokemonImage | null | undefined): string { + return image ? `${image.style} - ${image.version} - ${image.variant}` : ''; +} + +function pokemonImageCandidateForPath(id: number, path: string): PokemonImage | null { + const cleanPath = path.trim(); + const candidate = pokemonImageCandidates(id).find((item) => item.path === cleanPath); + return candidate ? pokemonImageWithUrl(candidate) : null; +} + +function cleanPokemonImage(value: unknown, pokemonId: number): PokemonImage | null { + const path = typeof value === 'string' ? value.trim() : ''; + if (path === '') { + return null; + } + + const image = pokemonImageCandidateForPath(pokemonId, path); + if (!image) { + throw validationError('Pokemon image path is invalid'); + } + return image; +} + +async function pokemonImageExists(candidate: PokemonImageCandidate): Promise { + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), pokemonSpriteRequestTimeoutMs); + + try { + const response = await fetch(pokemonSpriteUrl(candidate.path), { method: 'HEAD', signal: controller.signal }); + return response.ok; + } catch { + return false; + } finally { + clearTimeout(timeout); + } +} + function assignTranslation(translations: TranslationInput, locale: string, fieldName: TranslationField, value: string): void { if (!value) { return; @@ -1158,6 +1404,29 @@ export async function fetchPokemonData(payload: Record, userId: }; } +export async function fetchPokemonImageOptions(payload: Record): Promise { + const lookupKey = pokemonDataLookupKey(payload.identifier); + const data = await loadPokemonCsvData(); + const pokemonRow = data.pokemonByLookup.get(lookupKey); + + if (!pokemonRow) { + throw validationError('Pokemon data was not found'); + } + + const id = csvInteger(pokemonRow, 'id'); + const images = ( + await Promise.all( + pokemonImageCandidates(id).map(async (candidate) => (await pokemonImageExists(candidate) ? pokemonImageWithUrl(candidate) : null)) + ) + ).filter((image): image is PokemonImage => image !== null); + + return { + id, + identifier: csvText(pokemonRow, 'identifier'), + images + }; +} + function pokemonFetchOption(row: CsvRow, data: PokemonCsvData, languages: LanguagePayload[], locale: string): PokemonFetchOption { const id = csvInteger(row, 'id'); const identifier = csvText(row, 'identifier'); @@ -1361,6 +1630,7 @@ async function pokemonEditChanges( 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, 'Image', pokemonImageLabel(before.image), pokemonImageLabel(after.image)); 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)); @@ -1479,6 +1749,14 @@ function pokemonProjection(locale: string): string { 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", + CASE WHEN p.image_path <> '' THEN json_build_object( + 'path', p.image_path, + 'url', '${pokemonSpriteBaseUrl}' || p.image_path, + 'style', p.image_style, + 'version', p.image_version, + 'variant', p.image_variant, + 'description', p.image_description + ) ELSE NULL END AS image, json_build_object( 'hp', p.hp, 'attack', p.attack, @@ -2847,8 +3125,10 @@ function cleanPokemonPayload(payload: Record): PokemonPayload { } } + const id = requirePositiveInteger(payload.id, 'Pokemon ID is required'); + return { - id: requirePositiveInteger(payload.id, 'Pokemon ID is required'), + id, name: cleanName(payload.name, 'Pokemon name is required'), genus: cleanOptionalText(payload.genus), details: cleanOptionalText(payload.details), @@ -2860,7 +3140,8 @@ function cleanPokemonPayload(payload: Record): PokemonPayload { environmentId: requirePositiveInteger(payload.environmentId, 'Ideal Habitat is required'), skillIds, favoriteThingIds, - skillItemDrops: [...skillItemDrops.values()] + skillItemDrops: [...skillItemDrops.values()], + image: cleanPokemonImage(payload.imagePath, id) }; } @@ -2930,11 +3211,16 @@ export async function createPokemon(payload: Record, userId: nu special_attack, special_defense, speed, + image_path, + image_style, + image_version, + image_variant, + image_description, 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) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16, $17, $18, $19, $20, $20) `, [ cleanPayload.id, @@ -2950,6 +3236,11 @@ export async function createPokemon(payload: Record, userId: nu cleanPayload.stats.specialAttack, cleanPayload.stats.specialDefense, cleanPayload.stats.speed, + cleanPayload.image?.path ?? '', + cleanPayload.image?.style ?? '', + cleanPayload.image?.version ?? '', + cleanPayload.image?.variant ?? '', + cleanPayload.image?.description ?? '', sortOrder, userId ] @@ -2983,9 +3274,14 @@ export async function updatePokemon(id: number, payload: Record special_attack = $10, special_defense = $11, speed = $12, - updated_by_user_id = $13, + image_path = $13, + image_style = $14, + image_version = $15, + image_variant = $16, + image_description = $17, + updated_by_user_id = $18, updated_at = now() - WHERE id = $14 + WHERE id = $19 `, [ cleanPayload.name, @@ -3000,6 +3296,11 @@ export async function updatePokemon(id: number, payload: Record cleanPayload.stats.specialAttack, cleanPayload.stats.specialDefense, cleanPayload.stats.speed, + cleanPayload.image?.path ?? '', + cleanPayload.image?.style ?? '', + cleanPayload.image?.version ?? '', + cleanPayload.image?.variant ?? '', + cleanPayload.image?.description ?? '', userId, id ] diff --git a/backend/src/server.ts b/backend/src/server.ts index 6e73a14..f3ca3f6 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -29,6 +29,7 @@ import { deletePokemon, deleteRecipe, fetchPokemonData, + fetchPokemonImageOptions, getHabitat, getItem, getOptions, @@ -372,6 +373,11 @@ app.post('/api/pokemon/fetch', async (request, reply) => { return user ? fetchPokemonData(request.body as Record, user.id) : undefined; }); +app.post('/api/pokemon/image-options', async (request, reply) => { + const user = await requireVerifiedUser(request, reply); + return user ? fetchPokemonImageOptions(request.body as Record) : undefined; +}); + app.put('/api/pokemon/:id', async (request, reply) => { const user = await requireVerifiedUser(request, reply); if (!user) { diff --git a/backend/src/systemWordingQueries.ts b/backend/src/systemWordingQueries.ts index 4a2e05d..bf8f866 100644 --- a/backend/src/systemWordingQueries.ts +++ b/backend/src/systemWordingQueries.ts @@ -35,6 +35,7 @@ const legacyMessageKeys = new Map([ ['Pokemon identifier is required', 'server.validation.pokemonIdentifierRequired'], ['Pokemon type data is unavailable', 'server.validation.pokemonTypeDataUnavailable'], ['Pokemon data was not found', 'server.validation.pokemonDataNotFound'], + ['Pokemon image path is invalid', 'server.validation.pokemonImagePathInvalid'], ['Please enter a task', 'server.validation.taskRequired'], ['Please select a task', 'server.validation.selectTask'], ['Task does not exist', 'server.validation.taskDoesNotExist'], diff --git a/frontend/src/components/EditHistoryPanel.vue b/frontend/src/components/EditHistoryPanel.vue index 393041c..48819dc 100644 --- a/frontend/src/components/EditHistoryPanel.vue +++ b/frontend/src/components/EditHistoryPanel.vue @@ -15,6 +15,8 @@ const changeLabelKeys: Record = { Genus: 'pages.pokemon.genus', Details: 'pages.pokemon.details', 介绍: 'pages.pokemon.details', + Image: 'pages.pokemon.image', + 图片: 'pages.pokemon.image', Height: 'pages.pokemon.height', 身高: 'pages.pokemon.height', Weight: 'pages.pokemon.weight', diff --git a/frontend/src/components/EntityCard.vue b/frontend/src/components/EntityCard.vue index e5aeee1..503ac77 100644 --- a/frontend/src/components/EntityCard.vue +++ b/frontend/src/components/EntityCard.vue @@ -9,13 +9,15 @@ defineProps<{ to?: string; icon?: AppIcon; marker?: string; + image?: { src: string; alt: string }; }>();