feat: add skill item drops configuration for pokemon

Add `has_item_drop` flag to skills and `pokemon_skill_item_drops` table
Enable configuring item drops for specific pokemon skills in editor
Show skill item drops on pokemon and item detail pages
This commit is contained in:
2026-04-30 16:02:43 +08:00
parent 02f6dd47c3
commit a7086823ff
9 changed files with 344 additions and 66 deletions

View File

@@ -1,15 +1,21 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue';
import { computed, onMounted, ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import PageHeader from '../components/PageHeader.vue';
import Skeleton from '../components/Skeleton.vue';
import StatusMessage from '../components/StatusMessage.vue';
import TagsSelect from '../components/TagsSelect.vue';
import { api, type ConfigType, type Options, type PokemonPayload } from '../services/api';
import { api, type ConfigType, type NamedEntity, type Options, type PokemonPayload } from '../services/api';
type SkillItemDropForm = {
skillId: string;
itemId: string;
};
const route = useRoute();
const router = useRouter();
const options = ref<Options | null>(null);
const itemOptions = ref<NamedEntity[]>([]);
const loading = ref(true);
const busy = ref(false);
const message = ref('');
@@ -19,13 +25,17 @@ const pokemonForm = ref({
name: '',
environmentId: '',
skillIds: [] as string[],
favoriteThingIds: [] as string[]
favoriteThingIds: [] as string[],
skillItemDrops: [] as SkillItemDropForm[]
});
const routeId = computed(() => (typeof route.params.id === 'string' ? route.params.id : ''));
const isEditing = computed(() => routeId.value !== '');
const pageTitle = computed(() => (isEditing.value ? `编辑 #${pokemonForm.value.id || routeId.value} ${pokemonForm.value.name}` : '新增 Pokemon'));
const cancelTo = computed(() => (isEditing.value ? `/pokemon/${routeId.value}` : '/pokemon'));
const selectedSkillDropRows = computed(() =>
pokemonForm.value.skillItemDrops.filter((row) => pokemonForm.value.skillIds.includes(row.skillId) && skillSupportsItemDrop(row.skillId))
);
function toIds(values: string[]): number[] {
return values.map(Number).filter((item) => Number.isInteger(item) && item > 0);
@@ -36,7 +46,35 @@ function errorText(error: unknown, fallback: string) {
}
async function loadOptions() {
options.value = await api.options();
const [loadedOptions, loadedItems] = await Promise.all([api.options(), api.items({})]);
options.value = loadedOptions;
itemOptions.value = loadedItems.map((item) => ({ id: item.id, name: item.name }));
}
function syncSkillItemDrops() {
const selectedSkillIds = new Set(pokemonForm.value.skillIds);
const rows = pokemonForm.value.skillItemDrops.filter((row) => selectedSkillIds.has(row.skillId) && skillSupportsItemDrop(row.skillId));
pokemonForm.value.skillIds.forEach((skillId) => {
if (skillSupportsItemDrop(skillId) && !rows.some((row) => row.skillId === skillId)) {
rows.push({ skillId, itemId: '' });
}
});
pokemonForm.value.skillItemDrops = rows;
}
function skillName(skillId: string) {
return options.value?.skills.find((skill) => String(skill.id) === skillId)?.name ?? '';
}
function skillSupportsItemDrop(skillId: string) {
return options.value?.skills.some((skill) => String(skill.id) === skillId && skill.hasItemDrop) === true;
}
function skillDropLabel(skillId: string) {
const name = skillName(skillId);
return name ? `${name}掉落物` : '掉落物';
}
async function loadEditor() {
@@ -52,8 +90,13 @@ async function loadEditor() {
name: pokemon.name,
environmentId: String(pokemon.environment.id),
skillIds: pokemon.skills.map((skill) => String(skill.id)),
favoriteThingIds: pokemon.favorite_things.map((thing) => String(thing.id))
favoriteThingIds: pokemon.favorite_things.map((thing) => String(thing.id)),
skillItemDrops: pokemon.skills.map((skill) => ({
skillId: String(skill.id),
itemId: skill.itemDrop ? String(skill.itemDrop.id) : ''
}))
};
syncSkillItemDrops();
}
} catch (error) {
message.value = errorText(error, '加载失败');
@@ -109,7 +152,10 @@ async function savePokemon() {
name: pokemonForm.value.name,
environmentId: Number(pokemonForm.value.environmentId),
skillIds: toIds(pokemonForm.value.skillIds.slice(0, 2)),
favoriteThingIds: toIds(pokemonForm.value.favoriteThingIds.slice(0, 6))
favoriteThingIds: toIds(pokemonForm.value.favoriteThingIds.slice(0, 6)),
skillItemDrops: selectedSkillDropRows.value
.map((row) => ({ skillId: Number(row.skillId), itemId: Number(row.itemId) }))
.filter((row) => Number.isInteger(row.skillId) && row.skillId > 0 && Number.isInteger(row.itemId) && row.itemId > 0)
};
const saved = isEditing.value ? await api.updatePokemon(routeId.value, payload) : await api.createPokemon(payload);
await router.push(`/pokemon/${saved.id}`);
@@ -123,6 +169,8 @@ async function savePokemon() {
onMounted(() => {
void loadEditor();
});
watch(() => pokemonForm.value.skillIds.slice(), syncSkillItemDrops);
</script>
<template>
@@ -190,6 +238,23 @@ onMounted(() => {
/>
</div>
<div v-if="selectedSkillDropRows.length" class="field">
<span class="field-label">特长掉落物</span>
<div class="skill-drop-list">
<div v-for="row in selectedSkillDropRows" :key="row.skillId" class="skill-drop-row">
<label :for="`pokemon-skill-drops-${row.skillId}`">{{ skillDropLabel(row.skillId) }}</label>
<TagsSelect
:id="`pokemon-skill-drops-${row.skillId}`"
v-model="row.itemId"
:options="itemOptions"
:multiple="false"
placeholder="选择掉落物品"
search-placeholder="搜索物品"
/>
</div>
</div>
</div>
<div class="form-actions">
<button type="submit" class="link-button" :disabled="busy">{{ busy ? '保存中' : '保存' }}</button>
<RouterLink class="plain-button" :to="cancelTo">取消</RouterLink>