feat(frontend): add thumbnail support to TagsSelect component

Display item and pokemon images in dropdown options and selected values
Update Admin, Dish, Habitat, Pokemon, and Recipe views to pass image URLs
This commit is contained in:
2026-05-07 09:59:10 +08:00
parent bcf8dd9cb5
commit b1cf40edd0
7 changed files with 71 additions and 15 deletions

View File

@@ -8,12 +8,14 @@ export type TagsSelectOption = {
id: number | string;
name: string;
label?: string;
thumbnailUrl?: string | null;
};
type OptionRow = {
value: string;
label: string;
id: string;
thumbnailUrl: string | null;
};
type CandidateRow = { type: 'option'; id: string; value: string; label: string } | { type: 'create'; id: string };
@@ -65,7 +67,8 @@ const optionRows = computed(() =>
props.options.map((option, index) => ({
value: String(option.id),
label: option.label ?? option.name,
id: `${props.id}-option-${index}`
id: `${props.id}-option-${index}`,
thumbnailUrl: option.thumbnailUrl ?? null
}))
);
@@ -79,9 +82,10 @@ const maxReached = computed(() => props.multiple && props.max > 0 && modelValues
const selectedRows = computed(() =>
modelValues.value
.map((value) => optionRows.value.find((option) => option.value === value))
.filter((option) => option !== undefined)
.filter((option): option is OptionRow => option !== undefined)
);
const selectedLabel = computed(() => selectedRows.value[0]?.label ?? '');
const selectedThumbnailUrl = computed(() => selectedRows.value[0]?.thumbnailUrl ?? '');
const filteredRows = computed(() => {
const keyword = search.value.trim().toLowerCase();
@@ -360,6 +364,7 @@ watch(
<span v-if="selectedRows.length" class="tags-select__selected">
<template v-if="multiple">
<span v-for="option in selectedRows" :key="option.value" class="tags-select__tag">
<img v-if="option.thumbnailUrl" class="tags-select__thumb tags-select__thumb--tag" :src="option.thumbnailUrl" alt="" loading="lazy" />
<span>{{ option.label }}</span>
<span
class="tags-select__remove"
@@ -374,7 +379,10 @@ watch(
</span>
</span>
</template>
<span v-else class="tags-select__single-value">{{ selectedLabel }}</span>
<span v-else class="tags-select__single-value">
<img v-if="selectedThumbnailUrl" class="tags-select__thumb" :src="selectedThumbnailUrl" alt="" loading="lazy" />
<span>{{ selectedLabel }}</span>
</span>
</span>
<span v-else class="tags-select__placeholder">{{ placeholderText }}</span>
<Icon :icon="iconChevronDown" class="tags-select__arrow" aria-hidden="true" />
@@ -417,7 +425,10 @@ watch(
:disabled="!selectedValues.has(option.value) && maxReached"
@click="selectOption(option.value)"
>
<span>{{ option.label }}</span>
<span class="tags-select__option-label">
<img v-if="option.thumbnailUrl" class="tags-select__thumb" :src="option.thumbnailUrl" alt="" loading="lazy" />
<span>{{ option.label }}</span>
</span>
<span v-if="selectedValues.has(option.value)" class="tags-select__state">
<Icon :icon="iconCheck" class="ui-icon" aria-hidden="true" />
{{ t('common.selected') }}

View File

@@ -2041,10 +2041,18 @@ button:disabled,
}
.tags-select__single-value {
display: block;
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
max-width: 100%;
overflow: hidden;
color: var(--ink);
}
.tags-select__single-value span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
@@ -2054,6 +2062,7 @@ button:disabled,
align-items: center;
justify-content: center;
gap: 6px;
min-width: 0;
min-height: 28px;
padding: 4px 8px;
border: 1px solid rgba(42, 117, 187, 0.28);
@@ -2064,6 +2073,27 @@ button:disabled,
font-weight: 850;
}
.tags-select__tag > span:first-of-type {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
.tags-select__thumb {
flex: 0 0 auto;
width: 28px;
height: 28px;
border: 1px solid var(--line);
border-radius: var(--radius-small);
background: var(--surface-soft);
object-fit: contain;
}
.tags-select__thumb--tag {
width: 20px;
height: 20px;
}
.tags-select__remove {
min-width: 18px;
min-height: 18px;
@@ -2145,6 +2175,20 @@ button:disabled,
cursor: pointer;
}
.tags-select__option-label {
display: flex;
align-items: center;
flex: 1 1 auto;
gap: 8px;
min-width: 0;
}
.tags-select__option-label span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
}
.tags-select__option:hover,
.tags-select__option.active,
.tags-select__option.selected {

View File

@@ -371,7 +371,9 @@ const dishModalTitle = computed(() => (dishForm.value.id ? t('pages.dish.editDis
const dishRows = computed(() => dishCategoryRows.value.flatMap((category) => category.dishes));
const selectedDishFormCategory = computed(() => dishCategoryRows.value.find((category) => String(category.id) === dishForm.value.categoryId) ?? null);
const dishAllowsSecondSecondaryMaterial = computed(() => (selectedDishFormCategory.value?.totalMaterialQuantity ?? 0) > 2);
const dishItemSelectOptions = computed<TagsSelectOption[]>(() => dishItemRows.value.map((item) => ({ id: item.id, name: item.name })));
const dishItemSelectOptions = computed<TagsSelectOption[]>(() =>
dishItemRows.value.map((item) => ({ id: item.id, name: item.name, thumbnailUrl: item.image?.url }))
);
const optionalDishItemSelectOptions = computed<TagsSelectOption[]>(() => [{ id: '', name: t('common.none') }, ...dishItemSelectOptions.value]);
const dishCategorySelectOptions = computed<TagsSelectOption[]>(() =>
dishCategoryRows.value.map((category) => ({ id: category.id, name: category.name }))

View File

@@ -73,7 +73,7 @@ const dishCategoryModalTitle = computed(() =>
);
const dishModalTitle = computed(() => (dishForm.value.id ? t('pages.dish.editDish') : t('pages.dish.newDish')));
const itemSelectOptions = computed<TagsSelectOption[]>(() =>
items.value.map((item) => ({ id: item.id, name: item.name }))
items.value.map((item) => ({ id: item.id, name: item.name, thumbnailUrl: item.image?.url }))
);
const optionalItemSelectOptions = computed<TagsSelectOption[]>(() => [{ id: '', name: t('common.none') }, ...itemSelectOptions.value]);
const categorySelectOptions = computed<TagsSelectOption[]>(() => categories.value.map((category) => ({ id: category.id, name: category.name })));

View File

@@ -73,9 +73,9 @@ const weatherOptions = computed(() => [
const routeId = computed(() => (typeof route.params.id === 'string' ? route.params.id : ''));
const isEditing = computed(() => routeId.value !== '');
const isEventCreate = computed(() => route.name === 'event-habitat-new');
const itemSelectOptions = computed(() => itemRows.value.map((item) => ({ id: item.id, name: item.name })));
const itemSelectOptions = computed(() => itemRows.value.map((item) => ({ id: item.id, name: item.name, thumbnailUrl: item.image?.url })));
const pokemonSelectOptions = computed(() =>
pokemonRows.value.map((pokemon) => ({ id: pokemon.id, name: pokemon.name, label: `#${pokemon.displayId} ${pokemon.name}` }))
pokemonRows.value.map((pokemon) => ({ id: pokemon.id, name: pokemon.name, label: `#${pokemon.displayId} ${pokemon.name}`, thumbnailUrl: pokemon.image?.url }))
);
const pageTitle = computed(() =>
isEditing.value

View File

@@ -9,7 +9,7 @@ import PokemonStatsFields from '../components/PokemonStatsFields.vue';
import Skeleton from '../components/Skeleton.vue';
import StatusMessage from '../components/StatusMessage.vue';
import Tabs from '../components/Tabs.vue';
import TagsSelect from '../components/TagsSelect.vue';
import TagsSelect, { type TagsSelectOption } from '../components/TagsSelect.vue';
import TranslationFields from '../components/TranslationFields.vue';
import { iconCancel, iconSave, iconSearch } from '../icons';
import {
@@ -19,7 +19,6 @@ import {
type EntityImage,
type EntityImageUpload,
type Language,
type NamedEntity,
type Options,
type PokemonFetchOption,
type PokemonFetchResult,
@@ -39,7 +38,7 @@ const route = useRoute();
const router = useRouter();
const { locale, t } = useI18n();
const options = ref<Options | null>(null);
const itemOptions = ref<NamedEntity[]>([]);
const itemOptions = ref<TagsSelectOption[]>([]);
const languages = ref<Language[]>([]);
const currentUser = ref<AuthUser | null>(null);
const loading = ref(true);
@@ -189,7 +188,7 @@ function errorText(error: unknown, fallback: string) {
async function loadOptions() {
const [loadedOptions, loadedItems, loadedLanguages] = await Promise.all([api.options(), api.items({}), api.languages()]);
options.value = loadedOptions;
itemOptions.value = loadedItems.map((item) => ({ id: item.id, name: item.name }));
itemOptions.value = loadedItems.map((item) => ({ id: item.id, name: item.name, thumbnailUrl: item.image?.url }));
languages.value = loadedLanguages;
}

View File

@@ -28,11 +28,11 @@ const recipeForm = ref({
const routeId = computed(() => (typeof route.params.id === 'string' ? route.params.id : ''));
const isEditing = computed(() => routeId.value !== '');
const materialItemOptions = computed(() => itemRows.value.map((item) => ({ id: item.id, name: item.name })));
const materialItemOptions = computed(() => itemRows.value.map((item) => ({ id: item.id, name: item.name, thumbnailUrl: item.image?.url })));
const resultItemOptions = computed(() =>
itemRows.value
.filter((item) => !item.noRecipe || String(item.id) === recipeForm.value.itemId)
.map((item) => ({ id: item.id, name: item.name }))
.map((item) => ({ id: item.id, name: item.name, thumbnailUrl: item.image?.url }))
);
const selectedItemName = computed(() => resultItemOptions.value.find((item) => String(item.id) === recipeForm.value.itemId)?.name ?? '');
const pageTitle = computed(() =>