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:
2026-05-02 20:59:33 +08:00
parent 475e3577dd
commit cf0ae566c0
13 changed files with 749 additions and 20 deletions

View File

@@ -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',

View File

@@ -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>

View File

@@ -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),

View File

@@ -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;

View File

@@ -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')">

View File

@@ -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>

View File

@@ -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">