feat(ui): support inline creation of new options in TagsSelect
Add allowCreate prop and create event to TagsSelect component Integrate inline creation for tags, skills, and methods in admin forms
This commit is contained in:
@@ -16,17 +16,24 @@ const props = withDefaults(
|
|||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
searchPlaceholder?: string;
|
searchPlaceholder?: string;
|
||||||
emptyText?: string;
|
emptyText?: string;
|
||||||
|
allowCreate?: boolean;
|
||||||
|
creating?: boolean;
|
||||||
|
createLabel?: string;
|
||||||
}>(),
|
}>(),
|
||||||
{
|
{
|
||||||
max: 0,
|
max: 0,
|
||||||
placeholder: '搜索或选择',
|
placeholder: '搜索或选择',
|
||||||
searchPlaceholder: '搜索',
|
searchPlaceholder: '搜索',
|
||||||
emptyText: '没有匹配项'
|
emptyText: '没有匹配项',
|
||||||
|
allowCreate: false,
|
||||||
|
creating: false,
|
||||||
|
createLabel: '添加「{name}」'
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
'update:modelValue': [value: string[]];
|
'update:modelValue': [value: string[]];
|
||||||
|
create: [name: string];
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
const root = ref<HTMLElement | null>(null);
|
const root = ref<HTMLElement | null>(null);
|
||||||
@@ -55,6 +62,13 @@ const filteredRows = computed(() => {
|
|||||||
if (!keyword) return optionRows.value;
|
if (!keyword) return optionRows.value;
|
||||||
return optionRows.value.filter((option) => option.label.toLowerCase().includes(keyword));
|
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() {
|
async function openDropdown() {
|
||||||
isOpen.value = true;
|
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) {
|
function onRootKeydown(event: KeyboardEvent) {
|
||||||
if (event.key === 'Escape') {
|
if (event.key === 'Escape') {
|
||||||
closeDropdown();
|
closeDropdown();
|
||||||
@@ -172,7 +192,17 @@ onBeforeUnmount(() => {
|
|||||||
<span>{{ option.label }}</span>
|
<span>{{ option.label }}</span>
|
||||||
<span v-if="selectedValues.has(option.value)" class="tags-select__state">已选</span>
|
<span v-if="selectedValues.has(option.value)" class="tags-select__state">已选</span>
|
||||||
</button>
|
</button>
|
||||||
<p v-if="!filteredRows.length" class="tags-select__empty">{{ emptyText }}</p>
|
<button
|
||||||
|
v-if="canCreate"
|
||||||
|
type="button"
|
||||||
|
class="tags-select__option tags-select__create"
|
||||||
|
:disabled="creating"
|
||||||
|
@click="createOption"
|
||||||
|
>
|
||||||
|
<span>{{ createText }}</span>
|
||||||
|
<span v-if="creating" class="tags-select__state">添加中</span>
|
||||||
|
</button>
|
||||||
|
<p v-if="!filteredRows.length && !canCreate" class="tags-select__empty">{{ emptyText }}</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -260,6 +260,17 @@ select {
|
|||||||
font-weight: 800;
|
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 {
|
.tags-select__option:disabled {
|
||||||
cursor: not-allowed;
|
cursor: not-allowed;
|
||||||
opacity: 0.45;
|
opacity: 0.45;
|
||||||
|
|||||||
@@ -61,6 +61,7 @@ const recipeRows = ref<Recipe[]>([]);
|
|||||||
const habitatRows = ref<Habitat[]>([]);
|
const habitatRows = ref<Habitat[]>([]);
|
||||||
const busy = ref(false);
|
const busy = ref(false);
|
||||||
const message = ref('');
|
const message = ref('');
|
||||||
|
const creatingSelect = ref('');
|
||||||
|
|
||||||
const configForm = ref({ id: 0, name: '', subcategory: '' });
|
const configForm = ref({ id: 0, name: '', subcategory: '' });
|
||||||
const pokemonForm = ref({ id: '', name: '', environmentId: '', skillIds: [] as string[], favoriteThingIds: [] as string[] });
|
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[];
|
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() {
|
async function loadPokemon() {
|
||||||
pokemonRows.value = await api.pokemon({});
|
pokemonRows.value = await api.pokemon({});
|
||||||
}
|
}
|
||||||
@@ -496,7 +519,10 @@ onMounted(() => {
|
|||||||
v-model="pokemonForm.skillIds"
|
v-model="pokemonForm.skillIds"
|
||||||
:options="options.skills"
|
:options="options.skills"
|
||||||
:max="2"
|
:max="2"
|
||||||
|
allow-create
|
||||||
|
:creating="creatingSelect === 'pokemon-skills'"
|
||||||
placeholder="搜索特长"
|
placeholder="搜索特长"
|
||||||
|
@create="createTagsOption('pokemon-skills', 'skills', $event, pokemonForm.skillIds, 2)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
@@ -506,7 +532,10 @@ onMounted(() => {
|
|||||||
v-model="pokemonForm.favoriteThingIds"
|
v-model="pokemonForm.favoriteThingIds"
|
||||||
:options="options.favoriteThings"
|
:options="options.favoriteThings"
|
||||||
:max="6"
|
:max="6"
|
||||||
|
allow-create
|
||||||
|
:creating="creatingSelect === 'pokemon-things'"
|
||||||
placeholder="搜索喜欢的东西"
|
placeholder="搜索喜欢的东西"
|
||||||
|
@create="createTagsOption('pokemon-things', 'favorite-things', $event, pokemonForm.favoriteThingIds, 6)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
@@ -565,12 +594,23 @@ onMounted(() => {
|
|||||||
id="item-methods"
|
id="item-methods"
|
||||||
v-model="itemForm.acquisitionMethodIds"
|
v-model="itemForm.acquisitionMethodIds"
|
||||||
:options="options.acquisitionMethods"
|
:options="options.acquisitionMethods"
|
||||||
|
allow-create
|
||||||
|
:creating="creatingSelect === 'item-methods'"
|
||||||
placeholder="搜索入手方式"
|
placeholder="搜索入手方式"
|
||||||
|
@create="createTagsOption('item-methods', 'acquisition-methods', $event, itemForm.acquisitionMethodIds)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label for="item-tags">标签</label>
|
<label for="item-tags">标签</label>
|
||||||
<TagsSelect id="item-tags" v-model="itemForm.tagIds" :options="options.itemTags" placeholder="搜索标签" />
|
<TagsSelect
|
||||||
|
id="item-tags"
|
||||||
|
v-model="itemForm.tagIds"
|
||||||
|
:options="options.itemTags"
|
||||||
|
allow-create
|
||||||
|
:creating="creatingSelect === 'item-tags'"
|
||||||
|
placeholder="搜索标签"
|
||||||
|
@create="createTagsOption('item-tags', 'favorite-things', $event, itemForm.tagIds)"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="form-actions">
|
<div class="form-actions">
|
||||||
<button type="submit" class="link-button" :disabled="busy">保存</button>
|
<button type="submit" class="link-button" :disabled="busy">保存</button>
|
||||||
@@ -602,7 +642,10 @@ onMounted(() => {
|
|||||||
id="recipe-methods"
|
id="recipe-methods"
|
||||||
v-model="recipeForm.acquisitionMethodIds"
|
v-model="recipeForm.acquisitionMethodIds"
|
||||||
:options="options.acquisitionMethods"
|
:options="options.acquisitionMethods"
|
||||||
|
allow-create
|
||||||
|
:creating="creatingSelect === 'recipe-methods'"
|
||||||
placeholder="搜索入手方式"
|
placeholder="搜索入手方式"
|
||||||
|
@create="createTagsOption('recipe-methods', 'acquisition-methods', $event, recipeForm.acquisitionMethodIds)"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
@@ -660,7 +703,15 @@ onMounted(() => {
|
|||||||
<option value="">Pokemon</option>
|
<option value="">Pokemon</option>
|
||||||
<option v-for="item in pokemonRows" :key="item.id" :value="String(item.id)">#{{ item.id }} {{ item.name }}</option>
|
<option v-for="item in pokemonRows" :key="item.id" :value="String(item.id)">#{{ item.id }} {{ item.name }}</option>
|
||||||
</select>
|
</select>
|
||||||
<TagsSelect :id="`appearance-maps-${index}`" v-model="row.mapIds" :options="options.maps" placeholder="搜索地图" />
|
<TagsSelect
|
||||||
|
:id="`appearance-maps-${index}`"
|
||||||
|
v-model="row.mapIds"
|
||||||
|
:options="options.maps"
|
||||||
|
allow-create
|
||||||
|
:creating="creatingSelect === `appearance-maps-${index}`"
|
||||||
|
placeholder="搜索地图"
|
||||||
|
@create="createTagsOption(`appearance-maps-${index}`, 'maps', $event, row.mapIds)"
|
||||||
|
/>
|
||||||
<TagsSelect :id="`appearance-times-${index}`" v-model="row.timeOfDays" :options="timeOfDayOptions" placeholder="搜索时间" />
|
<TagsSelect :id="`appearance-times-${index}`" v-model="row.timeOfDays" :options="timeOfDayOptions" placeholder="搜索时间" />
|
||||||
<TagsSelect :id="`appearance-weathers-${index}`" v-model="row.weathers" :options="weatherOptions" placeholder="搜索天气" />
|
<TagsSelect :id="`appearance-weathers-${index}`" v-model="row.weathers" :options="weatherOptions" placeholder="搜索天气" />
|
||||||
<input v-model.number="row.rarity" type="number" min="1" max="3" />
|
<input v-model.number="row.rarity" type="number" min="1" max="3" />
|
||||||
|
|||||||
Reference in New Issue
Block a user