feat(frontend): enhance trading item search and keyboard navigation

Implement weighted search scoring for trading items
Add keyboard support (arrows, enter) for item selection
Limit trading detail list height with independent scrolling
This commit is contained in:
2026-05-06 22:11:09 +08:00
parent cc440ea949
commit df78685dc3
3 changed files with 196 additions and 14 deletions

View File

@@ -603,8 +603,8 @@ Pokemon 详情页展示:
- 六维使用 ProgressBar 展示,最大值按 150 计算。 - 六维使用 ProgressBar 展示,最大值按 150 计算。
- 特长 - 特长
- 特长掉落物品:当该 Pokemon 拥有支持掉落物的已选特长时默认展示;已配置时展示掉落物品图标,未配置时展示空状态 - 特长掉落物品:当该 Pokemon 拥有支持掉落物的已选特长时默认展示;已配置时展示掉落物品图标,未配置时展示空状态
- Trading当该 Pokemon 拥有支持 Trading 的已选特长时默认展示;分 Likes 与 Neutral 两组展示物品Likes 表示交易价格 1.5xNeutral 表示无加成,未配置观察时展示空状态 - Trading当该 Pokemon 拥有支持 Trading 的已选特长时默认展示;分 Likes 与 Neutral 两组展示物品Likes 表示交易价格 1.5xNeutral 表示无加成,未配置观察时展示空状态;详情页双列列表高度受限,内容较多时每列内部独立滚动,避免 Section 被大量物品无限拉长
- Trading 可在详情页通过 Manage Trading Modal 维护Modal 左侧顶部为同一行搜索框和分类下拉筛选,下面提供 Likes / Neutral 默认加入目标切换;从左侧选择物品会加入当前默认目标组,右侧按 Likes / Neutral 分组展示已选物品,并可切换分组或移除;列表区域高度稳定,过滤结果减少时 Modal 不应抖动 - Trading 可在详情页通过 Manage Trading Modal 维护Modal 左侧顶部为同一行搜索框和分类下拉筛选,下面提供 Likes / Neutral 默认加入目标切换;搜索结果优先展示名称精确匹配、名称开头匹配和单词匹配的物品再展示名称包含、分类或用途包含的物品搜索框支持上下键切换当前结果、左右键切换默认加入组、Enter 将当前结果加入默认目标组;从左侧选择物品会加入当前默认目标组,右侧按 Likes / Neutral 分组展示已选物品,并可切换分组或移除;列表区域高度稳定,过滤结果减少时 Modal 不应抖动
- 喜欢的环境 - 喜欢的环境
- 喜欢的东西 - 喜欢的东西
- 相关 Pokemon与关联喜欢的东西的物品在桌面端左右并排展示按相同喜欢的环境优先其次按共同喜欢的东西数量从多到少排序支持按喜欢的环境筛选默认筛选当前 Pokemon 的喜欢的环境,也可切换到其他喜欢的环境或全部;每个筛选视图最多展示 6 个;每项左侧展示 Pokemon 图片或默认 Poké Ball 占位符,原列表项信息布局保持不变,第一行左侧展示名称,右侧展示特长和喜欢的环境,第二行展示喜欢的东西,并高亮共同喜欢的东西 - 相关 Pokemon与关联喜欢的东西的物品在桌面端左右并排展示按相同喜欢的环境优先其次按共同喜欢的东西数量从多到少排序支持按喜欢的环境筛选默认筛选当前 Pokemon 的喜欢的环境,也可切换到其他喜欢的环境或全部;每个筛选视图最多展示 6 个;每项左侧展示 Pokemon 图片或默认 Poké Ball 占位符,原列表项信息布局保持不变,第一行左侧展示名称,右侧展示特长和喜欢的环境,第二行展示喜欢的东西,并高亮共同喜欢的东西

View File

@@ -5143,6 +5143,14 @@ button:disabled,
flex-wrap: wrap; flex-wrap: wrap;
} }
.trading-detail-list {
max-height: 360px;
overflow: auto;
padding-right: 4px;
overscroll-behavior: contain;
scrollbar-gutter: stable;
}
.trading-manager { .trading-manager {
min-height: 640px; min-height: 640px;
display: grid; display: grid;
@@ -5232,6 +5240,11 @@ button:disabled,
background: var(--surface-soft); background: var(--surface-soft);
} }
.trading-pick-row--active {
border-color: var(--pokemon-blue);
box-shadow: 0 0 0 2px rgba(42, 117, 187, .16);
}
.trading-pick-row__copy, .trading-pick-row__copy,
.trading-selected-list__copy { .trading-selected-list__copy {
min-width: 0; min-width: 0;
@@ -5320,6 +5333,10 @@ button:disabled,
max-height: 240px; max-height: 240px;
} }
.trading-detail-list {
max-height: 280px;
}
.trading-preference-toggle, .trading-preference-toggle,
.trading-selected-list .plain-button--icon { .trading-selected-list .plain-button--icon {
grid-column: 2; grid-column: 2;

View File

@@ -1,6 +1,6 @@
<script setup lang="ts"> <script setup lang="ts">
import { Icon } from '@iconify/vue'; import { Icon } from '@iconify/vue';
import { computed, onMounted, ref, watch } from 'vue'; import { computed, nextTick, onMounted, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
import { useRoute } from 'vue-router'; import { useRoute } from 'vue-router';
import DetailSection from '../components/DetailSection.vue'; import DetailSection from '../components/DetailSection.vue';
@@ -36,6 +36,7 @@ const tradingCategoryId = ref('');
const tradingDefaultPreference = ref<TradingPreference>('like'); const tradingDefaultPreference = ref<TradingPreference>('like');
const tradingItemChoices = ref<Item[]>([]); const tradingItemChoices = ref<Item[]>([]);
const tradingDraftItems = ref<Array<{ itemId: number; preference: TradingPreference }>>([]); const tradingDraftItems = ref<Array<{ itemId: number; preference: TradingPreference }>>([]);
const tradingActiveItemIndex = ref(0);
const timeOfDays = ['早晨', '中午', '傍晚', '晚上']; const timeOfDays = ['早晨', '中午', '傍晚', '晚上'];
const weathers = ['晴天', '阴天', '雨天']; const weathers = ['晴天', '阴天', '雨天'];
const relatedPokemonLimit = 6; const relatedPokemonLimit = 6;
@@ -198,20 +199,58 @@ const tradingCategoryOptions = computed(() => {
return [{ value: '', label: t('common.all') }, ...[...categories.entries()].map(([value, label]) => ({ value, label }))]; return [{ value: '', label: t('common.all') }, ...[...categories.entries()].map(([value, label]) => ({ value, label }))];
}); });
const tradingDraftPreferenceByItemId = computed(() => new Map(tradingDraftItems.value.map((item) => [String(item.itemId), item.preference]))); const tradingDraftPreferenceByItemId = computed(() => new Map(tradingDraftItems.value.map((item) => [String(item.itemId), item.preference])));
const filteredTradingItems = computed(() => { function normalizedTradingValue(value: string) {
const search = tradingSearch.value.trim().toLocaleLowerCase(); return value.trim().toLocaleLowerCase();
}
return tradingItemChoices.value.filter((item) => { function escapeRegExp(value: string) {
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function tradingSearchScore(item: Item, search: string) {
const name = normalizedTradingValue(item.name);
const category = normalizedTradingValue(item.category.name);
const usage = normalizedTradingValue(item.usage?.name ?? '');
if (name === search) {
return 0;
}
if (name.startsWith(search)) {
return 1;
}
if (new RegExp(`(^|\\s)${escapeRegExp(search)}`).test(name)) {
return 2;
}
if (name.includes(search)) {
return 3;
}
if (category.includes(search)) {
return 4;
}
if (usage.includes(search)) {
return 5;
}
return -1;
}
const filteredTradingItems = computed(() => {
const search = normalizedTradingValue(tradingSearch.value);
const rows = tradingItemChoices.value.flatMap((item, index) => {
if (tradingCategoryId.value && String(item.category.id) !== tradingCategoryId.value) { if (tradingCategoryId.value && String(item.category.id) !== tradingCategoryId.value) {
return false; return [];
} }
if (!search) { if (!search) {
return true; return [{ item, index, score: 0 }];
} }
return [item.name, item.category.name, item.usage?.name ?? ''].some((value) => value.toLocaleLowerCase().includes(search)); const score = tradingSearchScore(item, search);
return score >= 0 ? [{ item, index, score }] : [];
}); });
return rows.sort((a, b) => a.score - b.score || a.index - b.index).map((row) => row.item);
}); });
const tradingDraftGroups = computed(() => { const tradingDraftGroups = computed(() => {
const itemsById = new Map(tradingItemChoices.value.map((item) => [item.id, item])); const itemsById = new Map(tradingItemChoices.value.map((item) => [item.id, item]));
@@ -392,6 +431,7 @@ function resetTradingDraft() {
tradingDefaultPreference.value = 'like'; tradingDefaultPreference.value = 'like';
tradingSearch.value = ''; tradingSearch.value = '';
tradingCategoryId.value = ''; tradingCategoryId.value = '';
tradingActiveItemIndex.value = 0;
tradingMessage.value = ''; tradingMessage.value = '';
} }
@@ -399,13 +439,72 @@ function isTradingItemSelected(itemId: string | number) {
return tradingDraftPreferenceByItemId.value.has(String(itemId)); return tradingDraftPreferenceByItemId.value.has(String(itemId));
} }
function addTradingItem(item: Item) { function firstAddableTradingItemIndex(items = filteredTradingItems.value, startIndex = 0, direction: -1 | 1 = 1) {
if (!items.length) {
return 0;
}
const start = Math.min(Math.max(startIndex, 0), items.length - 1);
for (let offset = 0; offset < items.length; offset += 1) {
const index = (start + direction * offset + items.length) % items.length;
if (!isTradingItemSelected(items[index].id)) {
return index;
}
}
return start;
}
function setTradingActiveItemIndex(index: number) {
const maxIndex = filteredTradingItems.value.length - 1;
tradingActiveItemIndex.value = maxIndex >= 0 ? Math.min(Math.max(index, 0), maxIndex) : 0;
}
function moveTradingActiveItem(direction: -1 | 1) {
const items = filteredTradingItems.value;
if (!items.length) {
tradingActiveItemIndex.value = 0;
return;
}
const startIndex = Math.min(Math.max(tradingActiveItemIndex.value, 0), items.length - 1);
for (let offset = 1; offset <= items.length; offset += 1) {
const index = (startIndex + direction * offset + items.length) % items.length;
if (!isTradingItemSelected(items[index].id)) {
tradingActiveItemIndex.value = index;
return;
}
}
tradingActiveItemIndex.value = startIndex;
}
function activeTradingItemId() {
const item = filteredTradingItems.value[tradingActiveItemIndex.value];
return item ? `pokemon-trading-item-${item.id}` : undefined;
}
function scrollActiveTradingItemIntoView() {
if (typeof document === 'undefined') {
return;
}
nextTick(() => {
const activeId = activeTradingItemId();
if (activeId) {
document.getElementById(activeId)?.scrollIntoView({ block: 'nearest' });
}
});
}
function addTradingItem(item: Item, index = tradingActiveItemIndex.value) {
const itemId = String(item.id); const itemId = String(item.id);
if (isTradingItemSelected(itemId)) { if (isTradingItemSelected(itemId)) {
return; return;
} }
tradingDraftItems.value.push({ itemId: item.id, preference: tradingDefaultPreference.value }); tradingDraftItems.value.push({ itemId: item.id, preference: tradingDefaultPreference.value });
tradingActiveItemIndex.value = firstAddableTradingItemIndex(filteredTradingItems.value, index, 1);
} }
function removeTradingItem(itemId: string | number) { function removeTradingItem(itemId: string | number) {
@@ -421,6 +520,61 @@ function setTradingPreference(itemId: string | number, preference: TradingPrefer
} }
} }
function handleTradingSearchKeydown(event: KeyboardEvent) {
if (event.key === 'ArrowDown') {
event.preventDefault();
moveTradingActiveItem(1);
return;
}
if (event.key === 'ArrowUp') {
event.preventDefault();
moveTradingActiveItem(-1);
return;
}
if (event.key === 'ArrowLeft') {
event.preventDefault();
tradingDefaultPreference.value = 'like';
return;
}
if (event.key === 'ArrowRight') {
event.preventDefault();
tradingDefaultPreference.value = 'neutral';
return;
}
if (event.key === 'Enter') {
event.preventDefault();
const item = filteredTradingItems.value[tradingActiveItemIndex.value];
if (item) {
addTradingItem(item, tradingActiveItemIndex.value);
}
}
}
watch([tradingSearch, tradingCategoryId], () => {
tradingActiveItemIndex.value = firstAddableTradingItemIndex(filteredTradingItems.value, 0, 1);
scrollActiveTradingItemIntoView();
});
watch([filteredTradingItems, tradingDraftPreferenceByItemId], () => {
const items = filteredTradingItems.value;
if (!items.length) {
tradingActiveItemIndex.value = 0;
return;
}
const currentIndex = Math.min(Math.max(tradingActiveItemIndex.value, 0), items.length - 1);
tradingActiveItemIndex.value = isTradingItemSelected(items[currentIndex].id)
? firstAddableTradingItemIndex(items, currentIndex, 1)
: currentIndex;
scrollActiveTradingItemIntoView();
});
watch(tradingActiveItemIndex, scrollActiveTradingItemIntoView);
async function openTradingModal() { async function openTradingModal() {
if (!pokemon.value) { if (!pokemon.value) {
return; return;
@@ -877,7 +1031,13 @@ watch(initialPokemon, applyInitialPokemon, { immediate: true });
id="pokemon-trading-search" id="pokemon-trading-search"
v-model="tradingSearch" v-model="tradingSearch"
type="search" type="search"
autocomplete="off"
role="combobox"
:aria-expanded="filteredTradingItems.length > 0"
aria-controls="pokemon-trading-results"
:aria-activedescendant="activeTradingItemId()"
:placeholder="t('pages.pokemon.searchItems')" :placeholder="t('pages.pokemon.searchItems')"
@keydown="handleTradingSearchKeydown"
/> />
</div> </div>
@@ -909,14 +1069,19 @@ watch(initialPokemon, applyInitialPokemon, { immediate: true });
<Skeleton variant="box" height="58px" /> <Skeleton variant="box" height="58px" />
</li> </li>
</ul> </ul>
<ul v-else-if="filteredTradingItems.length" class="trading-item-list"> <ul v-else-if="filteredTradingItems.length" id="pokemon-trading-results" class="trading-item-list">
<li v-for="item in filteredTradingItems" :key="item.id"> <li v-for="(item, index) in filteredTradingItems" :id="`pokemon-trading-item-${item.id}`" :key="item.id">
<button <button
type="button" type="button"
class="trading-pick-row" class="trading-pick-row"
:class="{ 'trading-pick-row--selected': isTradingItemSelected(item.id) }" :class="{
'trading-pick-row--active': tradingActiveItemIndex === index,
'trading-pick-row--selected': isTradingItemSelected(item.id)
}"
:disabled="isTradingItemSelected(item.id)" :disabled="isTradingItemSelected(item.id)"
@click="addTradingItem(item)" @mouseenter="setTradingActiveItemIndex(index)"
@focus="setTradingActiveItemIndex(index)"
@click="addTradingItem(item, index)"
> >
<span class="related-entity-media related-entity-media--inline" aria-hidden="true"> <span class="related-entity-media related-entity-media--inline" aria-hidden="true">
<img v-if="item.image" :src="item.image.url" alt="" loading="lazy" /> <img v-if="item.image" :src="item.image.url" alt="" loading="lazy" />