feat(pokemon): add CSV data fetch to populate edit form

Allow users to search and fetch Pokemon data from local CSV files
Auto-populate basic fields, stats, types, and translations
Add type icons to Pokemon detail and list views
This commit is contained in:
2026-05-02 11:02:02 +08:00
parent b0d18a845d
commit e8e20539c9
16 changed files with 4226 additions and 14 deletions

View File

@@ -103,6 +103,17 @@ const messages = {
editTabAdvance: 'Advance',
newTitle: 'New Pokemon',
editTitle: 'Edit #{id} {name}',
fetchData: 'Fetch data',
fetchingData: 'Fetching',
fetchIdentifier: 'Data identifier',
fetchIdentifierPlaceholder: 'bulbasaur or 1',
fetchIdentifierRequired: 'Enter a Pokemon identifier',
fetchFailed: 'Pokemon data fetch failed',
fetchIdMismatch: 'Fetched Pokemon ID #{id} does not match this editor.',
fetchResults: 'Pokemon data results',
fetchSearching: 'Searching data',
fetchNoMatches: 'No matching Pokemon data',
fetchSearchFailed: 'Pokemon data search failed',
loadingList: 'Loading Pokemon list',
loadingDetail: 'Loading Pokemon detail',
loadingEdit: 'Loading Pokemon editor',
@@ -564,6 +575,17 @@ const messages = {
editTabAdvance: '进阶',
newTitle: '新增 Pokemon',
editTitle: '编辑 #{id} {name}',
fetchData: '获取数据',
fetchingData: '正在获取',
fetchIdentifier: '数据标识',
fetchIdentifierPlaceholder: 'bulbasaur 或 1',
fetchIdentifierRequired: '请输入 Pokemon 数据标识',
fetchFailed: 'Pokemon 数据获取失败',
fetchIdMismatch: '获取到的 Pokemon ID #{id} 与当前编辑内容不一致。',
fetchResults: 'Pokemon 数据结果',
fetchSearching: '正在搜索数据',
fetchNoMatches: '没有匹配的 Pokemon 数据',
fetchSearchFailed: 'Pokemon 数据搜索失败',
loadingList: '正在加载 Pokemon 列表',
loadingDetail: '正在加载 Pokemon 详情',
loadingEdit: '正在加载 Pokemon 编辑内容',

View File

@@ -290,6 +290,24 @@ export interface PokemonPayload {
skillItemDrops: Array<{ skillId: number; itemId: number }>;
}
export interface PokemonFetchResult {
id: number;
identifier: string;
name: string;
genus: string;
heightInches: number;
weightPounds: number;
translations?: TranslationMap;
typeIds: number[];
stats: PokemonStats;
}
export interface PokemonFetchOption {
id: number;
identifier: string;
name: string;
}
export interface ItemPayload {
name: string;
translations?: TranslationMap;
@@ -416,9 +434,10 @@ async function getErrorMessage(response: Response): Promise<string> {
return `Request failed (${response.status})`;
}
async function getJson<T>(path: string): Promise<T> {
async function getJson<T>(path: string, signal?: AbortSignal): Promise<T> {
const response = await fetch(`${apiBaseUrl}${path}`, {
headers: requestHeaders()
headers: requestHeaders(),
signal
});
if (!response.ok) {
@@ -550,6 +569,9 @@ export const api = {
pokemon: (params: Record<string, string | number | undefined>) =>
getJson<Pokemon[]>(`/api/pokemon${buildQuery(params)}`),
pokemonDetail: (id: string | number) => getJson<PokemonDetail>(`/api/pokemon/${id}`),
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 }),
createPokemon: (payload: PokemonPayload) => sendJson<PokemonDetail>('/api/pokemon', 'POST', payload),
updatePokemon: (id: string | number, payload: PokemonPayload) =>
sendJson<PokemonDetail>(`/api/pokemon/${id}`, 'PUT', payload),

View File

@@ -732,7 +732,84 @@ button:disabled,
.pokemon-edit-form {
height: clamp(420px, calc(100dvh - 188px), 640px);
min-height: 0;
grid-template-rows: auto minmax(0, 1fr);
grid-template-rows: auto auto minmax(0, 1fr);
}
.pokemon-fetch-panel {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 10px;
align-items: end;
padding: 10px;
border: 1px solid var(--line);
border-radius: var(--radius-card);
background: var(--surface-soft);
}
.pokemon-fetch-panel__input {
position: relative;
min-width: 0;
}
.pokemon-fetch-panel__button {
min-width: 118px;
justify-content: center;
}
.pokemon-fetch-results {
position: absolute;
top: calc(100% + 6px);
right: 0;
left: 0;
z-index: 35;
max-height: 260px;
overflow-y: auto;
display: grid;
gap: 4px;
padding: 6px;
border: 2px solid var(--line-strong);
border-radius: var(--radius-control);
background: var(--surface);
box-shadow: var(--shadow-raised);
}
.pokemon-fetch-option {
width: 100%;
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 8px;
align-items: center;
padding: 8px 10px;
border-radius: var(--radius-small);
background: transparent;
color: var(--ink);
text-align: left;
cursor: pointer;
}
.pokemon-fetch-option:hover,
.pokemon-fetch-option:focus-visible {
background: color-mix(in srgb, var(--pokemon-blue) 10%, var(--surface));
}
.pokemon-fetch-option__name {
min-width: 0;
font-weight: 900;
overflow-wrap: anywhere;
}
.pokemon-fetch-option__identifier {
color: var(--muted);
font-family: var(--font-mono);
font-size: 0.78rem;
}
.pokemon-fetch-results__status {
margin: 0;
padding: 10px;
color: var(--muted);
font-size: 0.88rem;
font-weight: 800;
}
.pokemon-edit-panel {
@@ -3131,6 +3208,18 @@ button:disabled,
min-width: 0;
}
.pokemon-type-chip {
gap: 7px;
min-height: 32px;
padding: 5px 10px 5px 7px;
}
.pokemon-type-chip__icon {
width: 22px;
height: 22px;
object-fit: contain;
}
.progress {
display: grid;
gap: 6px;
@@ -3713,6 +3802,7 @@ button:disabled,
.toolbar,
.entity-grid,
.grid,
.pokemon-fetch-panel,
.pokemon-edit-grid,
.coming-soon-preview {
grid-template-columns: 1fr;

View File

@@ -180,6 +180,10 @@ function formatImperialHeight(inches: number): string {
return `${feet}'${remainingInches}"`;
}
function pokemonTypeIconSrc(typeId: number): string | null {
return typeId >= 1 && typeId <= 19 ? `/types/small/${typeId}.png` : null;
}
async function loadPokemonDetail() {
const nextPokemon = await api.pokemonDetail(String(route.params.id));
pokemon.value = nextPokemon;
@@ -319,7 +323,10 @@ watch(
<section class="detail-section pokemon-profile-card pokemon-types-card" :aria-label="t('pages.pokemon.types')">
<div v-if="pokemon.types.length" class="pokemon-type-slots" :class="typeSlotClass">
<span v-for="type in pokemon.types.slice(0, 2)" :key="type.id" class="chip">{{ type.name }}</span>
<span v-for="type in pokemon.types.slice(0, 2)" :key="type.id" class="chip pokemon-type-chip">
<img v-if="pokemonTypeIconSrc(type.id)" class="pokemon-type-chip__icon" :src="pokemonTypeIconSrc(type.id) ?? undefined" alt="" aria-hidden="true" />
<span>{{ type.name }}</span>
</span>
</div>
<p v-else class="meta-line">{{ t('common.none') }}</p>
</section>

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
import { Icon } from '@iconify/vue';
import { computed, nextTick, onMounted, ref, watch } from 'vue';
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute, useRouter } from 'vue-router';
import Modal from '../components/Modal.vue';
@@ -10,13 +10,15 @@ import StatusMessage from '../components/StatusMessage.vue';
import Tabs from '../components/Tabs.vue';
import TagsSelect from '../components/TagsSelect.vue';
import TranslationFields from '../components/TranslationFields.vue';
import { iconCancel, iconSave } from '../icons';
import { iconCancel, iconSave, iconSearch } from '../icons';
import {
api,
type ConfigType,
type Language,
type NamedEntity,
type Options,
type PokemonFetchOption,
type PokemonFetchResult,
type PokemonPayload,
type PokemonStats,
type TranslationMap
@@ -35,11 +37,17 @@ const itemOptions = ref<NamedEntity[]>([]);
const languages = ref<Language[]>([]);
const loading = ref(true);
const busy = ref(false);
const fetchBusy = ref(false);
const fetchOptionsLoading = ref(false);
const fetchOptionsOpen = ref(false);
const message = ref('');
const fetchIdentifier = ref('');
const fetchOptions = ref<PokemonFetchOption[]>([]);
const creatingSelect = ref('');
const activeEditTab = ref('basic');
const heightUnit = ref<'imperial' | 'metric'>('imperial');
const weightUnit = ref<'imperial' | 'metric'>('imperial');
let fetchOptionsController: AbortController | null = null;
function defaultPokemonStats(): PokemonStats {
return {
@@ -174,6 +182,46 @@ function pokemonIdForSave() {
return Number(isEditing.value ? routeId.value : pokemonForm.value.id);
}
function mergeFetchedTranslations(fetchedTranslations: TranslationMap | undefined): TranslationMap {
const nextTranslations = Object.entries(pokemonForm.value.translations).reduce<TranslationMap>((translations, [code, fields]) => {
translations[code] = { ...fields };
return translations;
}, {});
Object.entries(fetchedTranslations ?? {}).forEach(([code, fields]) => {
const nextFields = { ...(nextTranslations[code] ?? {}) };
if (typeof fields.name === 'string') {
nextFields.name = fields.name;
}
if (typeof fields.genus === 'string') {
nextFields.genus = fields.genus;
}
nextTranslations[code] = nextFields;
});
return nextTranslations;
}
function applyFetchedPokemon(fetchedPokemon: PokemonFetchResult): boolean {
if (isEditing.value && fetchedPokemon.id !== pokemonIdForSave()) {
message.value = t('pages.pokemon.fetchIdMismatch', { id: fetchedPokemon.id });
return false;
}
pokemonForm.value = {
...pokemonForm.value,
id: isEditing.value ? pokemonForm.value.id : String(fetchedPokemon.id),
name: fetchedPokemon.name,
genus: fetchedPokemon.genus,
heightInches: fetchedPokemon.heightInches,
weightPounds: fetchedPokemon.weightPounds,
translations: mergeFetchedTranslations(fetchedPokemon.translations),
typeIds: fetchedPokemon.typeIds.map(String),
stats: fetchedPokemon.stats
};
return true;
}
function hasRequiredBasicFields() {
const id = pokemonIdForSave();
return Number.isInteger(id) && id > 0 && pokemonNameForSave().trim() !== '';
@@ -224,6 +272,95 @@ async function loadEditor() {
}
}
function cancelFetchOptionsRequest() {
fetchOptionsController?.abort();
fetchOptionsController = null;
fetchOptionsLoading.value = false;
}
function fetchOptionLabel(option: PokemonFetchOption) {
return `#${option.id} ${option.name}`;
}
async function loadFetchOptions() {
cancelFetchOptionsRequest();
const controller = new AbortController();
fetchOptionsController = controller;
fetchOptionsLoading.value = true;
try {
const rows = await api.pokemonFetchOptions(fetchIdentifier.value, controller.signal);
if (fetchOptionsController === controller) {
fetchOptions.value = rows;
}
} catch (error) {
if (error instanceof DOMException && error.name === 'AbortError') {
return;
}
if (fetchOptionsController === controller) {
fetchOptions.value = [];
message.value = errorText(error, t('pages.pokemon.fetchSearchFailed'));
}
} finally {
if (fetchOptionsController === controller) {
fetchOptionsLoading.value = false;
fetchOptionsController = null;
}
}
}
function refreshFetchOptions() {
if (!fetchOptionsOpen.value) {
return;
}
void loadFetchOptions();
}
function openFetchOptions() {
fetchOptionsOpen.value = true;
refreshFetchOptions();
}
function closeFetchOptions() {
fetchOptionsOpen.value = false;
cancelFetchOptionsRequest();
}
async function selectFetchOption(option: PokemonFetchOption) {
fetchIdentifier.value = option.identifier;
closeFetchOptions();
await fetchPokemonByIdentifier(option.identifier);
}
async function fetchPokemonByIdentifier(identifierValue?: string) {
const identifier = (identifierValue ?? fetchIdentifier.value).trim() || pokemonForm.value.id.trim();
if (!identifier) {
message.value = t('pages.pokemon.fetchIdentifierRequired');
return;
}
fetchBusy.value = true;
message.value = '';
try {
const fetchedPokemon = await api.fetchPokemonData(identifier);
await loadOptions();
if (applyFetchedPokemon(fetchedPokemon)) {
fetchIdentifier.value = fetchedPokemon.identifier;
closeFetchOptions();
}
} catch (error) {
message.value = errorText(error, t('pages.pokemon.fetchFailed'));
} finally {
fetchBusy.value = false;
}
}
function fetchPokemonFromInput() {
void fetchPokemonByIdentifier();
}
async function createSingleOption(selectKey: string, type: ConfigType, name: string, assign: (value: string) => void) {
const cleanName = name.trim();
if (!cleanName) return;
@@ -262,6 +399,10 @@ async function createMultiOption(selectKey: string, type: ConfigType, name: stri
}
async function savePokemon() {
if (fetchBusy.value) {
return;
}
if (!hasRequiredBasicFields()) {
await showBasicFieldValidation();
return;
@@ -301,7 +442,10 @@ onMounted(() => {
void loadEditor();
});
onBeforeUnmount(cancelFetchOptionsRequest);
watch(() => pokemonForm.value.skillIds.slice(), syncSkillItemDrops);
watch(fetchIdentifier, refreshFetchOptions);
</script>
<template>
@@ -311,6 +455,47 @@ watch(() => pokemonForm.value.skillIds.slice(), syncSkillItemDrops);
<form v-if="!loading && options" id="pokemon-edit-form" class="modal-edit-form modal-edit-form--tabbed pokemon-edit-form" @submit.prevent="savePokemon">
<Tabs id="pokemon-edit-tabs" v-model="activeEditTab" :tabs="editTabs" :label="t('pages.pokemon.editSections')" />
<div class="pokemon-fetch-panel" :aria-label="t('pages.pokemon.fetchData')">
<div class="field pokemon-fetch-panel__input">
<label for="pokemon-fetch-identifier">{{ t('pages.pokemon.fetchIdentifier') }}</label>
<input
id="pokemon-fetch-identifier"
v-model="fetchIdentifier"
type="search"
:placeholder="t('pages.pokemon.fetchIdentifierPlaceholder')"
autocomplete="off"
role="combobox"
:aria-expanded="fetchOptionsOpen"
aria-controls="pokemon-fetch-results"
@focus="openFetchOptions"
@keydown.escape.stop="closeFetchOptions"
@keydown.enter.prevent="fetchPokemonFromInput"
/>
<div v-if="fetchOptionsOpen" id="pokemon-fetch-results" class="pokemon-fetch-results" role="listbox" :aria-label="t('pages.pokemon.fetchResults')">
<p v-if="fetchOptionsLoading" class="pokemon-fetch-results__status">{{ t('pages.pokemon.fetchSearching') }}</p>
<template v-else-if="fetchOptions.length">
<button
v-for="option in fetchOptions"
:key="option.id"
type="button"
class="pokemon-fetch-option"
role="option"
@mousedown.prevent
@click="selectFetchOption(option)"
>
<span class="pokemon-fetch-option__name">{{ fetchOptionLabel(option) }}</span>
<span class="pokemon-fetch-option__identifier">{{ option.identifier }}</span>
</button>
</template>
<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>
<section v-if="activeEditTab === 'basic'" class="pokemon-edit-panel" role="tabpanel" :aria-label="t('pages.pokemon.editTabBasic')">
<div class="pokemon-edit-grid">
<div class="field">
@@ -476,10 +661,8 @@ watch(() => pokemonForm.value.skillIds.slice(), syncSkillItemDrops);
v-model="pokemonForm.typeIds"
:options="options.pokemonTypes"
:max="2"
allow-create
:creating="creatingSelect === 'pokemon-types'"
:placeholder="t('pages.pokemon.searchTypes')"
@create="createMultiOption('pokemon-types', 'pokemon-types', $event, pokemonForm.typeIds, 2)"
/>
</div>
@@ -498,7 +681,7 @@ watch(() => pokemonForm.value.skillIds.slice(), syncSkillItemDrops);
</section>
<template v-if="!loading && options" #footer>
<button type="submit" form="pokemon-edit-form" class="link-button" :disabled="busy">
<button type="submit" form="pokemon-edit-form" class="link-button" :disabled="busy || fetchBusy">
<Icon :icon="iconSave" class="ui-icon" aria-hidden="true" />
{{ busy ? t('common.saving') : t('common.save') }}
</button>

View File

@@ -44,6 +44,10 @@ async function loadPokemon() {
loading.value = false;
}
function pokemonTypeIconSrc(typeId: number): string | null {
return typeId >= 1 && typeId <= 19 ? `/types/small/${typeId}.png` : null;
}
onMounted(async () => {
options.value = await api.options();
await loadPokemon();
@@ -145,7 +149,12 @@ watch(query, loadPokemon);
:to="`/pokemon/${item.id}`"
>
<EditMeta :entity="item" />
<EntityChips v-if="item.types.length" :items="item.types" />
<div v-if="item.types.length" class="chips">
<span v-for="type in item.types" :key="type.id" class="chip pokemon-type-chip">
<img v-if="pokemonTypeIconSrc(type.id)" class="pokemon-type-chip__icon" :src="pokemonTypeIconSrc(type.id) ?? undefined" alt="" aria-hidden="true" />
<span>{{ type.name }}</span>
</span>
</div>
<EntityChips :items="item.skills" />
<EntityChips :items="item.favorite_things" />
</EntityCard>