diff --git a/frontend/src/components/TagsSelect.vue b/frontend/src/components/TagsSelect.vue index d3569c1..9cdeac0 100644 --- a/frontend/src/components/TagsSelect.vue +++ b/frontend/src/components/TagsSelect.vue @@ -16,17 +16,24 @@ const props = withDefaults( placeholder?: string; searchPlaceholder?: string; emptyText?: string; + allowCreate?: boolean; + creating?: boolean; + createLabel?: string; }>(), { max: 0, placeholder: '搜索或选择', searchPlaceholder: '搜索', - emptyText: '没有匹配项' + emptyText: '没有匹配项', + allowCreate: false, + creating: false, + createLabel: '添加「{name}」' } ); const emit = defineEmits<{ 'update:modelValue': [value: string[]]; + create: [name: string]; }>(); const root = ref(null); @@ -55,6 +62,13 @@ const filteredRows = computed(() => { if (!keyword) return optionRows.value; return optionRows.value.filter((option) => option.label.toLowerCase().includes(keyword)); }); +const createName = computed(() => search.value.trim()); +const hasExactMatch = computed(() => { + const keyword = createName.value.toLowerCase(); + return optionRows.value.some((option) => option.label.toLowerCase() === keyword); +}); +const canCreate = computed(() => props.allowCreate && createName.value !== '' && !hasExactMatch.value && !maxReached.value); +const createText = computed(() => props.createLabel.replace('{name}', createName.value)); async function openDropdown() { isOpen.value = true; @@ -96,6 +110,12 @@ function remove(value: string) { ); } +function createOption() { + if (!canCreate.value || props.creating) return; + emit('create', createName.value); + search.value = ''; +} + function onRootKeydown(event: KeyboardEvent) { if (event.key === 'Escape') { closeDropdown(); @@ -172,7 +192,17 @@ onBeforeUnmount(() => { {{ option.label }} 已选 -

{{ emptyText }}

+ +

{{ emptyText }}

diff --git a/frontend/src/styles/main.css b/frontend/src/styles/main.css index 1915148..b44db84 100644 --- a/frontend/src/styles/main.css +++ b/frontend/src/styles/main.css @@ -260,6 +260,17 @@ select { font-weight: 800; } +.tags-select__create { + border-top: 1px solid #ebe6da; + border-radius: 0; + color: #1f6f50; + font-weight: 800; +} + +.tags-select__create:hover { + background: #edf7ef; +} + .tags-select__option:disabled { cursor: not-allowed; opacity: 0.45; diff --git a/frontend/src/views/AdminView.vue b/frontend/src/views/AdminView.vue index 68f9c18..336bb53 100644 --- a/frontend/src/views/AdminView.vue +++ b/frontend/src/views/AdminView.vue @@ -61,6 +61,7 @@ const recipeRows = ref([]); const habitatRows = ref([]); const busy = ref(false); const message = ref(''); +const creatingSelect = ref(''); const configForm = ref({ id: 0, name: '', subcategory: '' }); const pokemonForm = ref({ id: '', name: '', environmentId: '', skillIds: [] as string[], favoriteThingIds: [] as string[] }); @@ -121,6 +122,28 @@ async function loadConfig() { configRows.value = (await api.config(activeConfigType.value)) as EditableConfig[]; } +async function createTagsOption(selectKey: string, type: ConfigType, name: string, values: string[], max = 0) { + const cleanName = name.trim(); + if (!cleanName || (max > 0 && values.length >= max)) return; + + creatingSelect.value = selectKey; + try { + await run(async () => { + const created = await api.createConfig(type, { name: cleanName, subcategory: null }); + await loadOptions(); + const value = String(created.id); + if (!values.includes(value)) { + values.push(value); + } + if (activeConfigType.value === type) { + await loadConfig(); + } + }); + } finally { + creatingSelect.value = ''; + } +} + async function loadPokemon() { pokemonRows.value = await api.pokemon({}); } @@ -496,7 +519,10 @@ onMounted(() => { v-model="pokemonForm.skillIds" :options="options.skills" :max="2" + allow-create + :creating="creatingSelect === 'pokemon-skills'" placeholder="搜索特长" + @create="createTagsOption('pokemon-skills', 'skills', $event, pokemonForm.skillIds, 2)" />
@@ -506,7 +532,10 @@ onMounted(() => { v-model="pokemonForm.favoriteThingIds" :options="options.favoriteThings" :max="6" + allow-create + :creating="creatingSelect === 'pokemon-things'" placeholder="搜索喜欢的东西" + @create="createTagsOption('pokemon-things', 'favorite-things', $event, pokemonForm.favoriteThingIds, 6)" />
@@ -565,12 +594,23 @@ onMounted(() => { id="item-methods" v-model="itemForm.acquisitionMethodIds" :options="options.acquisitionMethods" + allow-create + :creating="creatingSelect === 'item-methods'" placeholder="搜索入手方式" + @create="createTagsOption('item-methods', 'acquisition-methods', $event, itemForm.acquisitionMethodIds)" />
- +
@@ -602,7 +642,10 @@ onMounted(() => { id="recipe-methods" v-model="recipeForm.acquisitionMethodIds" :options="options.acquisitionMethods" + allow-create + :creating="creatingSelect === 'recipe-methods'" placeholder="搜索入手方式" + @create="createTagsOption('recipe-methods', 'acquisition-methods', $event, recipeForm.acquisitionMethodIds)" />
@@ -660,7 +703,15 @@ onMounted(() => { - +