feat(pokemon): add image selection and display from pokesprite
Add image metadata fields to Pokemon schema and API Implement image candidate fetching from pokesprite static tree Add Pokédex-style image picker to edit form and display in details
This commit is contained in:
14
DESIGN.md
14
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`
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<PokemonImage, 'url'>;
|
||||
|
||||
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<boolean> {
|
||||
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<string, unknown>, userId:
|
||||
};
|
||||
}
|
||||
|
||||
export async function fetchPokemonImageOptions(payload: Record<string, unknown>): Promise<PokemonImageOptionsResult> {
|
||||
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<string, unknown>): 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<string, unknown>): 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<string, unknown>, 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<string, unknown>, 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<string, unknown>
|
||||
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<string, unknown>
|
||||
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
|
||||
]
|
||||
|
||||
@@ -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<string, unknown>, 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<string, unknown>) : undefined;
|
||||
});
|
||||
|
||||
app.put('/api/pokemon/:id', async (request, reply) => {
|
||||
const user = await requireVerifiedUser(request, reply);
|
||||
if (!user) {
|
||||
|
||||
@@ -35,6 +35,7 @@ const legacyMessageKeys = new Map<string, string>([
|
||||
['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'],
|
||||
|
||||
@@ -15,6 +15,8 @@ const changeLabelKeys: Record<string, string> = {
|
||||
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',
|
||||
|
||||
@@ -9,13 +9,15 @@ defineProps<{
|
||||
to?: string;
|
||||
icon?: AppIcon;
|
||||
marker?: string;
|
||||
image?: { src: string; alt: string };
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<RouterLink v-if="to" class="entity-card entity-card--link" :to="to">
|
||||
<span class="entity-card__mark">
|
||||
<Icon v-if="icon" :icon="icon" class="entity-card__icon" aria-hidden="true" />
|
||||
<span class="entity-card__mark" :class="{ 'entity-card__mark--image': image }">
|
||||
<img v-if="image" class="entity-card__image" :src="image.src" :alt="image.alt" loading="lazy" />
|
||||
<Icon v-else-if="icon" :icon="icon" class="entity-card__icon" aria-hidden="true" />
|
||||
<PokeBallMark v-else-if="!marker" size="30px" />
|
||||
<span v-else>{{ marker }}</span>
|
||||
</span>
|
||||
@@ -27,8 +29,9 @@ defineProps<{
|
||||
</RouterLink>
|
||||
|
||||
<article v-else class="entity-card">
|
||||
<span class="entity-card__mark">
|
||||
<Icon v-if="icon" :icon="icon" class="entity-card__icon" aria-hidden="true" />
|
||||
<span class="entity-card__mark" :class="{ 'entity-card__mark--image': image }">
|
||||
<img v-if="image" class="entity-card__image" :src="image.src" :alt="image.alt" loading="lazy" />
|
||||
<Icon v-else-if="icon" :icon="icon" class="entity-card__icon" aria-hidden="true" />
|
||||
<PokeBallMark v-else-if="!marker" size="30px" />
|
||||
<span v-else>{{ marker }}</span>
|
||||
</span>
|
||||
|
||||
@@ -50,6 +50,15 @@ export interface PokemonStats {
|
||||
speed: number;
|
||||
}
|
||||
|
||||
export interface PokemonImage {
|
||||
path: string;
|
||||
url: string;
|
||||
style: string;
|
||||
version: string;
|
||||
variant: string;
|
||||
description: string;
|
||||
}
|
||||
|
||||
export interface UserSummary {
|
||||
id: number;
|
||||
displayName: string;
|
||||
@@ -89,6 +98,7 @@ export interface Pokemon extends EditInfo {
|
||||
heightMeters: number;
|
||||
weightPounds: number;
|
||||
weightKg: number;
|
||||
image: PokemonImage | null;
|
||||
translations?: TranslationMap;
|
||||
types: NamedEntity[];
|
||||
stats: PokemonStats;
|
||||
@@ -303,6 +313,7 @@ export interface PokemonPayload {
|
||||
skillIds: number[];
|
||||
favoriteThingIds: number[];
|
||||
skillItemDrops: Array<{ skillId: number; itemId: number }>;
|
||||
imagePath: string;
|
||||
}
|
||||
|
||||
export interface PokemonFetchResult {
|
||||
@@ -323,6 +334,12 @@ export interface PokemonFetchOption {
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface PokemonImageOptionsResult {
|
||||
id: number;
|
||||
identifier: string;
|
||||
images: PokemonImage[];
|
||||
}
|
||||
|
||||
export interface ItemPayload {
|
||||
name: string;
|
||||
translations?: TranslationMap;
|
||||
@@ -591,6 +608,8 @@ export const api = {
|
||||
pokemonFetchOptions: (search: string, signal?: AbortSignal) =>
|
||||
getJson<PokemonFetchOption[]>(`/api/pokemon/fetch-options${buildQuery({ search: search.trim() })}`, signal),
|
||||
fetchPokemonData: (identifier: string) => sendJson<PokemonFetchResult>('/api/pokemon/fetch', 'POST', { identifier }),
|
||||
fetchPokemonImageOptions: (identifier: string) =>
|
||||
sendJson<PokemonImageOptionsResult>('/api/pokemon/image-options', 'POST', { identifier }),
|
||||
createPokemon: (payload: PokemonPayload) => sendJson<PokemonDetail>('/api/pokemon', 'POST', payload),
|
||||
updatePokemon: (id: string | number, payload: PokemonPayload) =>
|
||||
sendJson<PokemonDetail>(`/api/pokemon/${id}`, 'PUT', payload),
|
||||
|
||||
@@ -751,6 +751,13 @@ button:disabled,
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.pokemon-fetch-panel__actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.pokemon-fetch-panel__button {
|
||||
min-width: 118px;
|
||||
justify-content: center;
|
||||
@@ -812,6 +819,115 @@ button:disabled,
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.pokemon-image-picker {
|
||||
display: grid;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.pokemon-image-preview {
|
||||
display: grid;
|
||||
gap: 12px;
|
||||
padding: 14px;
|
||||
border: 4px solid #172036;
|
||||
border-radius: var(--radius-card);
|
||||
background:
|
||||
linear-gradient(90deg, rgba(42, 117, 187, 0.08) 1px, transparent 1px) 0 0 / 18px 18px,
|
||||
linear-gradient(rgba(42, 117, 187, 0.08) 1px, transparent 1px) 0 0 / 18px 18px,
|
||||
#eef9ff;
|
||||
color: #172036;
|
||||
}
|
||||
|
||||
.pokemon-image-preview__screen {
|
||||
min-height: 220px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: 2px solid rgba(23, 32, 54, 0.18);
|
||||
border-radius: var(--radius-card);
|
||||
background:
|
||||
linear-gradient(135deg, rgba(255, 203, 5, 0.24), rgba(42, 117, 187, 0.12)),
|
||||
#ffffff;
|
||||
}
|
||||
|
||||
.pokemon-image-preview__screen img {
|
||||
width: min(100%, 360px);
|
||||
max-height: 220px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.pokemon-image-preview__caption {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.pokemon-image-preview__caption strong {
|
||||
color: #172036;
|
||||
font-family: var(--font-display);
|
||||
font-size: 1.1rem;
|
||||
font-weight: 950;
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
.pokemon-image-preview__caption span {
|
||||
color: #354052;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 900;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.pokemon-image-preview__caption p {
|
||||
margin: 0;
|
||||
color: #354052;
|
||||
}
|
||||
|
||||
.pokemon-image-thumbnails {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(112px, 1fr));
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.pokemon-image-thumbnail {
|
||||
min-height: 128px;
|
||||
display: grid;
|
||||
align-content: center;
|
||||
justify-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px;
|
||||
border: 2px solid var(--line-strong);
|
||||
border-radius: var(--radius-card);
|
||||
background: var(--surface);
|
||||
box-shadow: 0 2px 0 var(--line-strong);
|
||||
color: var(--ink);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.pokemon-image-thumbnail:hover,
|
||||
.pokemon-image-thumbnail:focus-visible {
|
||||
border-color: var(--pokemon-blue);
|
||||
}
|
||||
|
||||
.pokemon-image-thumbnail.active {
|
||||
background: color-mix(in srgb, var(--pokemon-yellow) 24%, var(--surface));
|
||||
border-color: var(--pokemon-blue-deep);
|
||||
}
|
||||
|
||||
.pokemon-image-thumbnail img {
|
||||
width: 86px;
|
||||
height: 76px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.pokemon-image-thumbnail span {
|
||||
color: var(--ink-soft);
|
||||
font-size: 0.78rem;
|
||||
font-weight: 900;
|
||||
text-align: center;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.pokemon-image-clear {
|
||||
justify-self: start;
|
||||
}
|
||||
|
||||
.pokemon-edit-panel {
|
||||
min-height: 0;
|
||||
display: grid;
|
||||
@@ -1305,11 +1421,24 @@ button:disabled,
|
||||
font-weight: 950;
|
||||
}
|
||||
|
||||
.entity-card__mark--image {
|
||||
padding: 3px;
|
||||
background:
|
||||
linear-gradient(135deg, rgba(255, 203, 5, 0.22), rgba(42, 117, 187, 0.12)),
|
||||
#ffffff;
|
||||
}
|
||||
|
||||
.entity-card__icon {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
}
|
||||
|
||||
.entity-card__image {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.entity-card__content {
|
||||
display: grid;
|
||||
align-content: start;
|
||||
@@ -3191,6 +3320,58 @@ button:disabled,
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.pokemon-image-detail {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(220px, 420px) minmax(0, 1fr);
|
||||
gap: 16px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.pokemon-image-detail__screen {
|
||||
min-height: 260px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border: 4px solid #172036;
|
||||
border-radius: var(--radius-card);
|
||||
background:
|
||||
linear-gradient(90deg, rgba(42, 117, 187, 0.08) 1px, transparent 1px) 0 0 / 18px 18px,
|
||||
linear-gradient(rgba(42, 117, 187, 0.08) 1px, transparent 1px) 0 0 / 18px 18px,
|
||||
#eef9ff;
|
||||
}
|
||||
|
||||
.pokemon-image-detail__screen img {
|
||||
width: min(100%, 380px);
|
||||
max-height: 250px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.pokemon-image-detail__caption {
|
||||
display: grid;
|
||||
gap: 6px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.pokemon-image-detail__caption strong {
|
||||
color: var(--ink);
|
||||
font-family: var(--font-display);
|
||||
font-size: 1.35rem;
|
||||
font-weight: 950;
|
||||
line-height: 1.15;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.pokemon-image-detail__caption span {
|
||||
color: var(--muted);
|
||||
font-size: 0.82rem;
|
||||
font-weight: 900;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.pokemon-image-detail__caption p {
|
||||
margin: 0;
|
||||
color: var(--ink-soft);
|
||||
}
|
||||
|
||||
.pokemon-profile-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(280px, 360px);
|
||||
@@ -3875,6 +4056,7 @@ button:disabled,
|
||||
}
|
||||
|
||||
.detail-grid,
|
||||
.pokemon-image-detail,
|
||||
.pokemon-profile-grid,
|
||||
.pokemon-profile-row,
|
||||
.pokemon-related-grid,
|
||||
@@ -3951,6 +4133,10 @@ button:disabled,
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.pokemon-fetch-panel__actions {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.coming-soon-panel {
|
||||
grid-template-columns: 1fr;
|
||||
padding: 18px;
|
||||
|
||||
@@ -184,6 +184,14 @@ function pokemonTypeIconSrc(typeId: number): string | null {
|
||||
return typeId >= 1 && typeId <= 19 ? `/types/small/${typeId}.png` : null;
|
||||
}
|
||||
|
||||
function pokemonImageAlt() {
|
||||
return pokemon.value?.image ? t('pages.pokemon.imageAlt', { name: pokemon.value.name, variant: pokemon.value.image.variant }) : '';
|
||||
}
|
||||
|
||||
function pokemonImageLabel() {
|
||||
return pokemon.value?.image ? `${pokemon.value.image.version} - ${pokemon.value.image.variant}` : '';
|
||||
}
|
||||
|
||||
async function loadPokemonDetail() {
|
||||
const nextPokemon = await api.pokemonDetail(String(route.params.id));
|
||||
pokemon.value = nextPokemon;
|
||||
@@ -290,6 +298,17 @@ watch(
|
||||
<Tabs id="pokemon-detail-tabs" v-model="detailTab" :tabs="detailTabs" :label="t('common.details')" />
|
||||
|
||||
<div v-if="detailTab === 'details'" class="detail-grid detail-grid--stack">
|
||||
<section v-if="pokemon.image" class="detail-section pokemon-image-detail" :aria-label="t('pages.pokemon.image')">
|
||||
<div class="pokemon-image-detail__screen">
|
||||
<img :src="pokemon.image.url" :alt="pokemonImageAlt()" />
|
||||
</div>
|
||||
<div class="pokemon-image-detail__caption">
|
||||
<strong>{{ pokemonImageLabel() }}</strong>
|
||||
<span>{{ pokemon.image.style }}</span>
|
||||
<p>{{ pokemon.image.description }}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<div class="pokemon-profile-grid">
|
||||
<div class="pokemon-profile-main">
|
||||
<section class="detail-section pokemon-profile-card" :aria-label="t('pages.pokemon.details')">
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
type Options,
|
||||
type PokemonFetchOption,
|
||||
type PokemonFetchResult,
|
||||
type PokemonImage,
|
||||
type PokemonPayload,
|
||||
type PokemonStats,
|
||||
type TranslationMap
|
||||
@@ -38,11 +39,14 @@ const languages = ref<Language[]>([]);
|
||||
const loading = ref(true);
|
||||
const busy = ref(false);
|
||||
const fetchBusy = ref(false);
|
||||
const imageBusy = ref(false);
|
||||
const fetchOptionsLoading = ref(false);
|
||||
const fetchOptionsOpen = ref(false);
|
||||
const message = ref('');
|
||||
const fetchIdentifier = ref('');
|
||||
const fetchOptions = ref<PokemonFetchOption[]>([]);
|
||||
const imageOptions = ref<PokemonImage[]>([]);
|
||||
const currentPokemonImage = ref<PokemonImage | null>(null);
|
||||
const creatingSelect = ref('');
|
||||
const activeEditTab = ref('basic');
|
||||
const heightUnit = ref<'imperial' | 'metric'>('imperial');
|
||||
@@ -73,7 +77,8 @@ const pokemonForm = ref({
|
||||
environmentId: '',
|
||||
skillIds: [] as string[],
|
||||
favoriteThingIds: [] as string[],
|
||||
skillItemDrops: [] as SkillItemDropForm[]
|
||||
skillItemDrops: [] as SkillItemDropForm[],
|
||||
imagePath: ''
|
||||
});
|
||||
|
||||
const routeId = computed(() => (typeof route.params.id === 'string' ? route.params.id : ''));
|
||||
@@ -97,6 +102,22 @@ const heightInchesValue = computed(() => totalHeightInchesValue.value - heightFe
|
||||
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));
|
||||
const selectedPokemonImage = computed(() => {
|
||||
const imagePath = pokemonForm.value.imagePath;
|
||||
if (!imagePath) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return imageOptions.value.find((image) => image.path === imagePath) ?? (currentPokemonImage.value?.path === imagePath ? currentPokemonImage.value : null);
|
||||
});
|
||||
const displayedImageOptions = computed(() => {
|
||||
const selectedImage = selectedPokemonImage.value;
|
||||
if (!selectedImage || imageOptions.value.some((image) => image.path === selectedImage.path)) {
|
||||
return imageOptions.value;
|
||||
}
|
||||
|
||||
return [selectedImage, ...imageOptions.value];
|
||||
});
|
||||
|
||||
function toIds(values: string[]): number[] {
|
||||
return values.map(Number).filter((item) => Number.isInteger(item) && item > 0);
|
||||
@@ -261,8 +282,11 @@ async function loadEditor() {
|
||||
skillItemDrops: pokemon.skills.map((skill) => ({
|
||||
skillId: String(skill.id),
|
||||
itemId: skill.itemDrop ? String(skill.itemDrop.id) : ''
|
||||
}))
|
||||
})),
|
||||
imagePath: pokemon.image?.path ?? ''
|
||||
};
|
||||
currentPokemonImage.value = pokemon.image;
|
||||
imageOptions.value = pokemon.image ? [pokemon.image] : [];
|
||||
syncSkillItemDrops();
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -327,6 +351,14 @@ function closeFetchOptions() {
|
||||
cancelFetchOptionsRequest();
|
||||
}
|
||||
|
||||
function handleFetchIdentifierInput() {
|
||||
fetchOptionsOpen.value = true;
|
||||
}
|
||||
|
||||
function closeFetchOptionsAfterBlur() {
|
||||
window.setTimeout(closeFetchOptions, 120);
|
||||
}
|
||||
|
||||
async function selectFetchOption(option: PokemonFetchOption) {
|
||||
fetchIdentifier.value = option.identifier;
|
||||
closeFetchOptions();
|
||||
@@ -361,6 +393,65 @@ function fetchPokemonFromInput() {
|
||||
void fetchPokemonByIdentifier();
|
||||
}
|
||||
|
||||
function pokemonImageLabel(image: PokemonImage) {
|
||||
return `${image.version} - ${image.variant}`;
|
||||
}
|
||||
|
||||
function pokemonImageAlt(image: PokemonImage) {
|
||||
const name = pokemonForm.value.name.trim() || (pokemonForm.value.id.trim() ? `#${pokemonForm.value.id.trim()}` : t('pages.pokemon.title'));
|
||||
return t('pages.pokemon.imageAlt', { name, variant: image.variant });
|
||||
}
|
||||
|
||||
function selectPokemonImage(image: PokemonImage) {
|
||||
pokemonForm.value.imagePath = image.path;
|
||||
currentPokemonImage.value = image;
|
||||
}
|
||||
|
||||
function clearPokemonImage() {
|
||||
pokemonForm.value.imagePath = '';
|
||||
currentPokemonImage.value = null;
|
||||
}
|
||||
|
||||
async function fetchPokemonImages() {
|
||||
const identifier = fetchIdentifier.value.trim() || pokemonForm.value.id.trim();
|
||||
if (!identifier) {
|
||||
message.value = t('pages.pokemon.fetchIdentifierRequired');
|
||||
return;
|
||||
}
|
||||
|
||||
imageBusy.value = true;
|
||||
message.value = '';
|
||||
|
||||
try {
|
||||
const result = await api.fetchPokemonImageOptions(identifier);
|
||||
const currentId = pokemonIdForSave();
|
||||
if (Number.isInteger(currentId) && currentId > 0 && result.id !== currentId) {
|
||||
message.value = t('pages.pokemon.fetchIdMismatch', { id: result.id });
|
||||
return;
|
||||
}
|
||||
|
||||
fetchIdentifier.value = result.identifier;
|
||||
imageOptions.value = result.images;
|
||||
|
||||
if (!result.images.length) {
|
||||
message.value = t('pages.pokemon.imageNoMatches');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!result.images.some((image) => image.path === pokemonForm.value.imagePath)) {
|
||||
selectPokemonImage(result.images[0]);
|
||||
}
|
||||
} catch (error) {
|
||||
message.value = errorText(error, t('pages.pokemon.imageFetchFailed'));
|
||||
} finally {
|
||||
imageBusy.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function fetchPokemonImagesFromInput() {
|
||||
void fetchPokemonImages();
|
||||
}
|
||||
|
||||
async function createSingleOption(selectKey: string, type: ConfigType, name: string, assign: (value: string) => void) {
|
||||
const cleanName = name.trim();
|
||||
if (!cleanName) return;
|
||||
@@ -399,7 +490,7 @@ async function createMultiOption(selectKey: string, type: ConfigType, name: stri
|
||||
}
|
||||
|
||||
async function savePokemon() {
|
||||
if (fetchBusy.value) {
|
||||
if (fetchBusy.value || imageBusy.value) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -427,7 +518,8 @@ async function savePokemon() {
|
||||
favoriteThingIds: toIds(pokemonForm.value.favoriteThingIds.slice(0, 6)),
|
||||
skillItemDrops: selectedSkillDropRows.value
|
||||
.map((row) => ({ skillId: Number(row.skillId), itemId: Number(row.itemId) }))
|
||||
.filter((row) => Number.isInteger(row.skillId) && row.skillId > 0 && Number.isInteger(row.itemId) && row.itemId > 0)
|
||||
.filter((row) => Number.isInteger(row.skillId) && row.skillId > 0 && Number.isInteger(row.itemId) && row.itemId > 0),
|
||||
imagePath: pokemonForm.value.imagePath
|
||||
};
|
||||
const saved = isEditing.value ? await api.updatePokemon(routeId.value, payload) : await api.createPokemon(payload);
|
||||
await router.push(`/pokemon/${saved.id}`);
|
||||
@@ -467,7 +559,9 @@ watch(fetchIdentifier, refreshFetchOptions);
|
||||
role="combobox"
|
||||
:aria-expanded="fetchOptionsOpen"
|
||||
aria-controls="pokemon-fetch-results"
|
||||
@focus="openFetchOptions"
|
||||
@click="openFetchOptions"
|
||||
@input="handleFetchIdentifierInput"
|
||||
@blur="closeFetchOptionsAfterBlur"
|
||||
@keydown.escape.stop="closeFetchOptions"
|
||||
@keydown.enter.prevent="fetchPokemonFromInput"
|
||||
/>
|
||||
@@ -490,10 +584,16 @@ watch(fetchIdentifier, refreshFetchOptions);
|
||||
<p v-else class="pokemon-fetch-results__status">{{ t('pages.pokemon.fetchNoMatches') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="ui-button ui-button--blue ui-button--small pokemon-fetch-panel__button" :disabled="busy || fetchBusy" @click="fetchPokemonFromInput">
|
||||
<Icon :icon="iconSearch" class="ui-icon" aria-hidden="true" />
|
||||
{{ fetchBusy ? t('pages.pokemon.fetchingData') : t('pages.pokemon.fetchData') }}
|
||||
</button>
|
||||
<div class="pokemon-fetch-panel__actions">
|
||||
<button type="button" class="ui-button ui-button--blue ui-button--small pokemon-fetch-panel__button" :disabled="busy || fetchBusy" @click="fetchPokemonFromInput">
|
||||
<Icon :icon="iconSearch" class="ui-icon" aria-hidden="true" />
|
||||
{{ fetchBusy ? t('pages.pokemon.fetchingData') : t('pages.pokemon.fetchData') }}
|
||||
</button>
|
||||
<button type="button" class="ui-button ui-button--blue ui-button--small pokemon-fetch-panel__button" :disabled="busy || imageBusy" @click="fetchPokemonImagesFromInput">
|
||||
<Icon :icon="iconSearch" class="ui-icon" aria-hidden="true" />
|
||||
{{ imageBusy ? t('pages.pokemon.fetchingImages') : t('pages.pokemon.fetchImages') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section v-if="activeEditTab === 'basic'" class="pokemon-edit-panel" role="tabpanel" :aria-label="t('pages.pokemon.editTabBasic')">
|
||||
@@ -575,6 +675,47 @@ watch(fetchIdentifier, refreshFetchOptions);
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="imageBusy" class="pokemon-image-preview pokemon-image-preview--loading" aria-busy="true" :aria-label="t('pages.pokemon.loadingImages')">
|
||||
<Skeleton variant="box" height="220px" />
|
||||
<Skeleton width="44%" />
|
||||
<Skeleton width="70%" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="selectedPokemonImage" class="pokemon-image-picker">
|
||||
<div class="pokemon-image-preview" :aria-label="t('pages.pokemon.selectedImage')">
|
||||
<div class="pokemon-image-preview__screen">
|
||||
<img :src="selectedPokemonImage.url" :alt="pokemonImageAlt(selectedPokemonImage)" />
|
||||
</div>
|
||||
<div class="pokemon-image-preview__caption">
|
||||
<strong>{{ pokemonImageLabel(selectedPokemonImage) }}</strong>
|
||||
<span>{{ selectedPokemonImage.style }}</span>
|
||||
<p>{{ selectedPokemonImage.description }}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="displayedImageOptions.length" class="pokemon-image-thumbnails" :aria-label="t('pages.pokemon.imageOptions')">
|
||||
<button
|
||||
v-for="image in displayedImageOptions"
|
||||
:key="image.path"
|
||||
type="button"
|
||||
class="pokemon-image-thumbnail"
|
||||
:class="{ active: image.path === pokemonForm.imagePath }"
|
||||
:aria-pressed="image.path === pokemonForm.imagePath"
|
||||
@click="selectPokemonImage(image)"
|
||||
>
|
||||
<img :src="image.url" :alt="pokemonImageAlt(image)" loading="lazy" />
|
||||
<span>{{ image.variant }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button type="button" class="plain-button pokemon-image-clear" @click="clearPokemonImage">
|
||||
<Icon :icon="iconCancel" class="ui-icon" aria-hidden="true" />
|
||||
{{ t('pages.pokemon.clearImage') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p v-else class="meta-line">{{ t('pages.pokemon.imageEmpty') }}</p>
|
||||
</section>
|
||||
|
||||
<section v-else class="pokemon-edit-panel" role="tabpanel" :aria-label="t('pages.pokemon.editTabAdvance')">
|
||||
@@ -681,7 +822,7 @@ watch(fetchIdentifier, refreshFetchOptions);
|
||||
</section>
|
||||
|
||||
<template v-if="!loading && options" #footer>
|
||||
<button type="submit" form="pokemon-edit-form" class="link-button" :disabled="busy || fetchBusy">
|
||||
<button type="submit" form="pokemon-edit-form" class="link-button" :disabled="busy || fetchBusy || imageBusy">
|
||||
<Icon :icon="iconSave" class="ui-icon" aria-hidden="true" />
|
||||
{{ busy ? t('common.saving') : t('common.save') }}
|
||||
</button>
|
||||
|
||||
@@ -48,6 +48,10 @@ function pokemonTypeIconSrc(typeId: number): string | null {
|
||||
return typeId >= 1 && typeId <= 19 ? `/types/small/${typeId}.png` : null;
|
||||
}
|
||||
|
||||
function pokemonCardImage(item: Pokemon) {
|
||||
return item.image ? { src: item.image.url, alt: t('pages.pokemon.imageAlt', { name: item.name, variant: item.image.variant }) } : undefined;
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
options.value = await api.options();
|
||||
await loadPokemon();
|
||||
@@ -147,6 +151,7 @@ watch(query, loadPokemon);
|
||||
:title="`#${item.id} ${item.name}`"
|
||||
:subtitle="t('pages.pokemon.environmentPrefix', { name: item.environment.name })"
|
||||
:to="`/pokemon/${item.id}`"
|
||||
:image="pokemonCardImage(item)"
|
||||
>
|
||||
<EditMeta :entity="item" />
|
||||
<div v-if="item.types.length" class="chips">
|
||||
|
||||
@@ -114,6 +114,17 @@ export const systemWordingMessages = {
|
||||
fetchSearching: 'Searching data',
|
||||
fetchNoMatches: 'No matching Pokemon data',
|
||||
fetchSearchFailed: 'Pokemon data search failed',
|
||||
image: 'Image',
|
||||
fetchImages: 'Fetch images',
|
||||
fetchingImages: 'Fetching',
|
||||
imageFetchFailed: 'Pokemon image fetch failed',
|
||||
imageNoMatches: 'No available Pokemon images',
|
||||
loadingImages: 'Loading Pokemon images',
|
||||
selectedImage: 'Selected Pokemon image',
|
||||
imageOptions: 'Pokemon image options',
|
||||
clearImage: 'Clear image',
|
||||
imageEmpty: 'No Pokemon image selected',
|
||||
imageAlt: '{name} {variant} image',
|
||||
loadingList: 'Loading Pokemon list',
|
||||
loadingDetail: 'Loading Pokemon detail',
|
||||
loadingEdit: 'Loading Pokemon editor',
|
||||
@@ -530,6 +541,7 @@ export const systemWordingMessages = {
|
||||
pokemonIdentifierRequired: 'Pokemon identifier is required',
|
||||
pokemonTypeDataUnavailable: 'Pokemon type data is unavailable',
|
||||
pokemonDataNotFound: 'Pokemon data was not found',
|
||||
pokemonImagePathInvalid: 'Pokemon image path is invalid',
|
||||
taskRequired: 'Please enter a task',
|
||||
selectTask: 'Please select a task',
|
||||
taskDoesNotExist: 'Task does not exist',
|
||||
@@ -691,6 +703,17 @@ export const systemWordingMessages = {
|
||||
fetchSearching: '正在搜索数据',
|
||||
fetchNoMatches: '没有匹配的 Pokemon 数据',
|
||||
fetchSearchFailed: 'Pokemon 数据搜索失败',
|
||||
image: '图片',
|
||||
fetchImages: '获取图片',
|
||||
fetchingImages: '正在获取',
|
||||
imageFetchFailed: 'Pokemon 图片获取失败',
|
||||
imageNoMatches: '没有可用的 Pokemon 图片',
|
||||
loadingImages: '正在加载 Pokemon 图片',
|
||||
selectedImage: '已选择的 Pokemon 图片',
|
||||
imageOptions: 'Pokemon 图片选项',
|
||||
clearImage: '清除图片',
|
||||
imageEmpty: '尚未选择 Pokemon 图片',
|
||||
imageAlt: '{name} {variant} 图片',
|
||||
loadingList: '正在加载 Pokemon 列表',
|
||||
loadingDetail: '正在加载 Pokemon 详情',
|
||||
loadingEdit: '正在加载 Pokemon 编辑内容',
|
||||
@@ -1107,6 +1130,7 @@ export const systemWordingMessages = {
|
||||
pokemonIdentifierRequired: '请输入 Pokemon 标识',
|
||||
pokemonTypeDataUnavailable: 'Pokemon 属性数据不可用',
|
||||
pokemonDataNotFound: '未找到 Pokemon 数据',
|
||||
pokemonImagePathInvalid: 'Pokemon 图片路径不合法',
|
||||
taskRequired: '请输入任务',
|
||||
selectTask: '请选择任务',
|
||||
taskDoesNotExist: '任务不存在',
|
||||
|
||||
Reference in New Issue
Block a user