diff --git a/DESIGN.md b/DESIGN.md index 9239ead..5d7fc7c 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -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。 diff --git a/backend/src/queries.ts b/backend/src/queries.ts index 6232895..6924a48 100644 --- a/backend/src/queries.ts +++ b/backend/src/queries.ts @@ -1804,12 +1804,13 @@ function pokemonFetchOptionMatches( export async function listPokemonFetchOptions(paramsQuery: QueryParams, locale = defaultLocale): Promise { 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 { diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index 791b9cc..da1ee4e 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -1008,8 +1008,11 @@ export const api = { pokemon: (params: Record) => getJson(`/api/pokemon${buildQuery(params)}`), pokemonDetail: (id: string | number) => getJson(`/api/pokemon/${id}`), - pokemonFetchOptions: (search: string, signal?: AbortSignal) => - getJson(`/api/pokemon/fetch-options${buildQuery({ search: search.trim() })}`, signal), + pokemonFetchOptions: (search: string, signal?: AbortSignal, all = false) => + getJson( + `/api/pokemon/fetch-options${buildQuery({ search: search.trim(), all: all ? true : undefined })}`, + signal + ), fetchPokemonData: (identifier: string) => sendJson('/api/pokemon/fetch', 'POST', { identifier }), fetchPokemonImageOptions: (identifier: string) => sendJson('/api/pokemon/image-options', 'POST', { identifier }), diff --git a/frontend/src/views/PokemonEdit.vue b/frontend/src/views/PokemonEdit.vue index dc00621..a0bcdcd 100644 --- a/frontend/src/views/PokemonEdit.vue +++ b/frontend/src/views/PokemonEdit.vue @@ -52,6 +52,9 @@ const message = ref(''); const fetchInput = ref(null); const fetchIdentifier = ref(''); const fetchOptions = ref([]); +const allFetchOptions = ref([]); +const fetchOptionsLoaded = ref(false); +const fetchOptionsFailed = ref(false); const fetchResultsStyle = ref({}); const imageOptions = ref([]); const currentPokemonImage = ref(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(); +});