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 受权限保护的编辑 API
- Pokemon、栖息地、物品、材料单的创建、更新、删除分别需要对应实体的 `create``update``delete` 权限。 - 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/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/pokemon/image-options`:按 data identifier 或 Pokemon ID 查询 pokesprite 可用图片候选;需要 `pokemon.fetch`;只返回 `id``identifier` 和图片候选列表。
- `POST /api/uploads/:entityType`:上传 Wiki 图片;需要对应实体上传权限;`entityType` 支持 `pokemon``items``habitats`;返回图片历史记录项和可展示 URL。 - `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[]> { export async function listPokemonFetchOptions(paramsQuery: QueryParams, locale = defaultLocale): Promise<PokemonFetchOption[]> {
const search = asString(paramsQuery.search)?.trim() ?? ''; const search = asString(paramsQuery.search)?.trim() ?? '';
const includeAll = asString(paramsQuery.all) === 'true';
const [data, languages] = await Promise.all([loadPokemonCsvData(), listLanguages()]); 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 return (includeAll ? rows : rows.slice(0, 20)).map((row) => pokemonFetchOption(row, data, languages, locale));
.filter((row) => csvInteger(row, 'id') > 0 && pokemonFetchOptionMatches(row, data, languages, locale, search))
.slice(0, 20)
.map((row) => pokemonFetchOption(row, data, languages, locale));
} }
function displayValue(value: string | null | undefined): string { function displayValue(value: string | null | undefined): string {

View File

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

View File

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