perf(pokemon): cache fetch options locally to reduce API requests

Add `all` parameter to fetch-options API to retrieve the full list.
Fetch all options once and filter locally in the Pokemon edit view to improve search responsiveness.
This commit is contained in:
2026-05-03 22:34:49 +08:00
parent df212a4e27
commit a0e07f101a
4 changed files with 63 additions and 9 deletions

View File

@@ -852,7 +852,7 @@ API 暴露边界:
受权限保护的编辑 API
- Pokemon、栖息地、物品、材料单的创建、更新、删除分别需要对应实体的 `create``update``delete` 权限。
- `GET /api/pokemon/fetch-options`:按搜索词返回 Pokemon CSV data 搜索结果;需要 `pokemon.fetch`;只返回 `id``identifier``name`
- `GET /api/pokemon/fetch-options`:按搜索词返回 Pokemon CSV data 搜索结果;支持 `all=true` 返回完整候选列表供前端本地筛选;需要 `pokemon.fetch`;只返回 `id``identifier``name`
- `POST /api/pokemon/fetch`:按 data identifier 或 Pokemon ID 查询 CSV 资料并填充 Pokemon 编辑表单;需要 `pokemon.fetch`;不直接保存 Pokemon。
- `POST /api/pokemon/image-options`:按 data identifier 或 Pokemon ID 查询 pokesprite 可用图片候选;需要 `pokemon.fetch`;只返回 `id``identifier` 和图片候选列表。
- `POST /api/uploads/:entityType`:上传 Wiki 图片;需要对应实体上传权限;`entityType` 支持 `pokemon``items``habitats`;返回图片历史记录项和可展示 URL。

View File

@@ -1804,12 +1804,13 @@ function pokemonFetchOptionMatches(
export async function listPokemonFetchOptions(paramsQuery: QueryParams, locale = defaultLocale): Promise<PokemonFetchOption[]> {
const search = asString(paramsQuery.search)?.trim() ?? '';
const includeAll = asString(paramsQuery.all) === 'true';
const [data, languages] = await Promise.all([loadPokemonCsvData(), listLanguages()]);
const rows = data.pokemonRows.filter(
(row) => csvInteger(row, 'id') > 0 && pokemonFetchOptionMatches(row, data, languages, locale, search)
);
return data.pokemonRows
.filter((row) => csvInteger(row, 'id') > 0 && pokemonFetchOptionMatches(row, data, languages, locale, search))
.slice(0, 20)
.map((row) => pokemonFetchOption(row, data, languages, locale));
return (includeAll ? rows : rows.slice(0, 20)).map((row) => pokemonFetchOption(row, data, languages, locale));
}
function displayValue(value: string | null | undefined): string {

View File

@@ -1008,8 +1008,11 @@ 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),
pokemonFetchOptions: (search: string, signal?: AbortSignal, all = false) =>
getJson<PokemonFetchOption[]>(
`/api/pokemon/fetch-options${buildQuery({ search: search.trim(), all: all ? true : undefined })}`,
signal
),
fetchPokemonData: (identifier: string) => sendJson<PokemonFetchResult>('/api/pokemon/fetch', 'POST', { identifier }),
fetchPokemonImageOptions: (identifier: string) =>
sendJson<PokemonImageOptionsResult>('/api/pokemon/image-options', 'POST', { identifier }),

View File

@@ -52,6 +52,9 @@ const message = ref('');
const fetchInput = ref<HTMLInputElement | null>(null);
const fetchIdentifier = ref('');
const fetchOptions = ref<PokemonFetchOption[]>([]);
const allFetchOptions = ref<PokemonFetchOption[]>([]);
const fetchOptionsLoaded = ref(false);
const fetchOptionsFailed = ref(false);
const fetchResultsStyle = ref<CSSProperties>({});
const imageOptions = ref<PokemonImage[]>([]);
const currentPokemonImage = ref<PokemonImage | null>(null);
@@ -62,6 +65,7 @@ const heightUnit = ref<'imperial' | 'metric'>('imperial');
const weightUnit = ref<'imperial' | 'metric'>('imperial');
let fetchOptionsController: AbortController | null = null;
let fetchPositionFrame = 0;
const fetchOptionsLimit = 20;
function defaultPokemonStats(): PokemonStats {
return {
@@ -328,24 +332,57 @@ function cancelFetchOptionsRequest() {
fetchOptionsLoading.value = false;
}
function resetFetchOptionsCache() {
cancelFetchOptionsRequest();
allFetchOptions.value = [];
fetchOptions.value = [];
fetchOptionsLoaded.value = false;
fetchOptionsFailed.value = false;
}
function fetchOptionLabel(option: PokemonFetchOption) {
return `#${option.id} ${option.name}`;
}
function fetchOptionMatchesSearch(option: PokemonFetchOption, search: string) {
if (!search) {
return true;
}
const keyword = search.toLowerCase();
return [String(option.id), option.identifier, option.name].some((value) => value.toLowerCase().includes(keyword));
}
function applyLocalFetchOptions() {
const search = fetchIdentifier.value.trim();
fetchOptions.value = allFetchOptions.value.filter((option) => fetchOptionMatchesSearch(option, search)).slice(0, fetchOptionsLimit);
}
async function loadFetchOptions() {
if (!canFetchPokemon.value) {
return;
}
if (fetchOptionsLoaded.value || fetchOptionsFailed.value) {
applyLocalFetchOptions();
return;
}
if (fetchOptionsController) {
return;
}
cancelFetchOptionsRequest();
const controller = new AbortController();
fetchOptionsController = controller;
fetchOptionsLoading.value = true;
try {
const rows = await api.pokemonFetchOptions(fetchIdentifier.value, controller.signal);
const rows = await api.pokemonFetchOptions('', controller.signal, true);
if (fetchOptionsController === controller) {
fetchOptions.value = rows;
allFetchOptions.value = rows;
fetchOptionsLoaded.value = true;
applyLocalFetchOptions();
}
} catch (error) {
if (error instanceof DOMException && error.name === 'AbortError') {
@@ -353,6 +390,7 @@ async function loadFetchOptions() {
}
if (fetchOptionsController === controller) {
fetchOptions.value = [];
fetchOptionsFailed.value = true;
message.value = errorText(error, t('pages.pokemon.fetchSearchFailed'));
}
} finally {
@@ -368,6 +406,11 @@ function refreshFetchOptions() {
return;
}
if (fetchOptionsLoaded.value || fetchOptionsFailed.value) {
applyLocalFetchOptions();
return;
}
void loadFetchOptions();
}
@@ -446,6 +489,9 @@ function closeFetchOptions() {
fetchResultsStyle.value = {};
removeFetchPositionListeners();
cancelFetchOptionsRequest();
if (!fetchOptionsLoaded.value) {
fetchOptionsFailed.value = false;
}
}
function handleFetchIdentifierInput() {
@@ -670,6 +716,10 @@ onBeforeUnmount(() => {
watch(() => pokemonForm.value.skillIds.slice(), syncSkillItemDrops);
watch(fetchIdentifier, refreshFetchOptions);
watch(locale, () => {
resetFetchOptionsCache();
refreshFetchOptions();
});
</script>
<template>