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:
@@ -7,6 +7,10 @@ export interface NamedEntity {
|
||||
name: string;
|
||||
}
|
||||
|
||||
export interface Skill extends NamedEntity {
|
||||
hasItemDrop: boolean;
|
||||
}
|
||||
|
||||
export interface UserSummary {
|
||||
id: number;
|
||||
displayName: string;
|
||||
@@ -23,11 +27,12 @@ export interface Pokemon extends EditInfo {
|
||||
id: number;
|
||||
name: string;
|
||||
environment: NamedEntity;
|
||||
skills: NamedEntity[];
|
||||
skills: Skill[];
|
||||
favorite_things: NamedEntity[];
|
||||
}
|
||||
|
||||
export interface PokemonDetail extends Pokemon {
|
||||
skills: Array<Skill & { itemDrop: NamedEntity | null }>;
|
||||
habitats: Array<{
|
||||
id: number;
|
||||
name: string;
|
||||
@@ -71,6 +76,10 @@ export interface ItemDetail extends Item {
|
||||
acquisitionMethods: NamedEntity[];
|
||||
recipe: RecipeDetail | null;
|
||||
relatedHabitats: Array<NamedEntity & { quantity: number }>;
|
||||
droppedByPokemon: Array<{
|
||||
pokemon: NamedEntity;
|
||||
skill: NamedEntity;
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface Recipe extends EditInfo {
|
||||
@@ -85,7 +94,7 @@ export interface RecipeDetail extends Recipe {
|
||||
}
|
||||
|
||||
export interface Options {
|
||||
skills: NamedEntity[];
|
||||
skills: Skill[];
|
||||
environments: NamedEntity[];
|
||||
favoriteThings: NamedEntity[];
|
||||
itemCategories: NamedEntity[];
|
||||
@@ -131,6 +140,7 @@ export interface PokemonPayload {
|
||||
environmentId: number;
|
||||
skillIds: number[];
|
||||
favoriteThingIds: number[];
|
||||
skillItemDrops: Array<{ skillId: number; itemId: number }>;
|
||||
}
|
||||
|
||||
export interface ItemPayload {
|
||||
@@ -279,11 +289,11 @@ export const api = {
|
||||
me: () => getJson<{ user: AuthUser }>('/api/auth/me'),
|
||||
logout: () => postEmpty('/api/auth/logout'),
|
||||
options: () => getJson<Options>('/api/options'),
|
||||
config: (type: ConfigType) => getJson<NamedEntity[]>(`/api/admin/config/${type}`),
|
||||
createConfig: (type: ConfigType, payload: { name: string }) =>
|
||||
sendJson<NamedEntity>(`/api/admin/config/${type}`, 'POST', payload),
|
||||
updateConfig: (type: ConfigType, id: number, payload: { name: string }) =>
|
||||
sendJson<NamedEntity>(`/api/admin/config/${type}/${id}`, 'PUT', payload),
|
||||
config: (type: ConfigType) => getJson<Array<Skill | NamedEntity>>(`/api/admin/config/${type}`),
|
||||
createConfig: (type: ConfigType, payload: { name: string; hasItemDrop?: boolean }) =>
|
||||
sendJson<Skill | NamedEntity>(`/api/admin/config/${type}`, 'POST', payload),
|
||||
updateConfig: (type: ConfigType, id: number, payload: { name: string; hasItemDrop?: boolean }) =>
|
||||
sendJson<Skill | NamedEntity>(`/api/admin/config/${type}/${id}`, 'PUT', payload),
|
||||
deleteConfig: (type: ConfigType, id: number) => deleteJson(`/api/admin/config/${type}/${id}`),
|
||||
pokemon: (params: Record<string, string | number | undefined>) =>
|
||||
getJson<Pokemon[]>(`/api/pokemon${buildQuery(params)}`),
|
||||
|
||||
@@ -854,6 +854,20 @@ button:disabled,
|
||||
font-weight: 750;
|
||||
}
|
||||
|
||||
.config-flag {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
min-height: 24px;
|
||||
margin-left: 8px;
|
||||
padding: 3px 7px;
|
||||
border: 1px solid rgba(42, 117, 187, 0.24);
|
||||
border-radius: var(--radius-small);
|
||||
background: var(--surface-soft);
|
||||
color: var(--pokemon-blue-deep);
|
||||
font-size: 12px;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -944,6 +958,14 @@ button:disabled,
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.skill-drop-summary li {
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.skill-drop-summary .chips {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.appearance-list li {
|
||||
display: grid;
|
||||
grid-template-columns: max-content minmax(0, 1fr);
|
||||
@@ -1166,6 +1188,24 @@ button:disabled,
|
||||
width: 90px;
|
||||
}
|
||||
|
||||
.skill-drop-list {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.skill-drop-row {
|
||||
display: grid;
|
||||
gap: 8px;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-card);
|
||||
background: var(--surface-soft);
|
||||
}
|
||||
|
||||
.skill-drop-row label {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.check-row label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -12,11 +12,12 @@ import {
|
||||
type Item,
|
||||
type NamedEntity,
|
||||
type Pokemon,
|
||||
type Recipe
|
||||
type Recipe,
|
||||
type Skill
|
||||
} from '../services/api';
|
||||
|
||||
type AdminTab = 'config' | 'pokemon' | 'items' | 'recipes' | 'habitats';
|
||||
type EditableConfig = NamedEntity;
|
||||
type EditableConfig = (NamedEntity | Skill) & { hasItemDrop?: boolean };
|
||||
|
||||
const tabs: Array<{ key: AdminTab; label: string }> = [
|
||||
{ key: 'config', label: '系统配置' },
|
||||
@@ -26,8 +27,8 @@ const tabs: Array<{ key: AdminTab; label: string }> = [
|
||||
{ key: 'habitats', label: '栖息地' }
|
||||
];
|
||||
|
||||
const configTypes: Array<{ key: ConfigType; label: string }> = [
|
||||
{ key: 'skills', label: '特长' },
|
||||
const configTypes: Array<{ key: ConfigType; label: string; supportsItemDrop?: boolean }> = [
|
||||
{ key: 'skills', label: '特长', supportsItemDrop: true },
|
||||
{ key: 'environments', label: '喜欢的环境' },
|
||||
{ key: 'favorite-things', label: '喜欢的东西 / 标签' },
|
||||
{ key: 'item-categories', label: '物品分类' },
|
||||
@@ -47,7 +48,7 @@ const currentUser = ref<AuthUser | null>(null);
|
||||
const busy = ref(false);
|
||||
const contentLoading = ref(false);
|
||||
const message = ref('');
|
||||
const configForm = ref({ id: 0, name: '' });
|
||||
const configForm = ref({ id: 0, name: '', hasItemDrop: false });
|
||||
|
||||
const selectedConfig = computed(() => configTypes.find((item) => item.key === activeConfigType.value) ?? configTypes[0]);
|
||||
const configTabs = computed<TabOption[]>(() => configTypes.map((item) => ({ value: item.key, label: item.label })));
|
||||
@@ -86,17 +87,18 @@ async function loadConfig() {
|
||||
}
|
||||
|
||||
function resetConfigForm() {
|
||||
configForm.value = { id: 0, name: '' };
|
||||
configForm.value = { id: 0, name: '', hasItemDrop: false };
|
||||
}
|
||||
|
||||
function editConfig(item: EditableConfig) {
|
||||
configForm.value = { id: item.id, name: item.name };
|
||||
configForm.value = { id: item.id, name: item.name, hasItemDrop: item.hasItemDrop === true };
|
||||
}
|
||||
|
||||
async function saveConfig() {
|
||||
await run(async () => {
|
||||
const payload = {
|
||||
name: configForm.value.name
|
||||
name: configForm.value.name,
|
||||
hasItemDrop: selectedConfig.value.supportsItemDrop ? configForm.value.hasItemDrop : undefined
|
||||
};
|
||||
|
||||
if (configForm.value.id) {
|
||||
@@ -245,6 +247,12 @@ onMounted(() => {
|
||||
<label for="config-name">名称</label>
|
||||
<input id="config-name" v-model="configForm.name" required />
|
||||
</div>
|
||||
<div v-if="selectedConfig.supportsItemDrop" class="check-row">
|
||||
<label>
|
||||
<input v-model="configForm.hasItemDrop" type="checkbox" />
|
||||
有掉落物
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="link-button" :disabled="busy">{{ busy ? '保存中' : '保存' }}</button>
|
||||
<button type="button" class="plain-button" :disabled="busy" @click="resetConfigForm">新建</button>
|
||||
@@ -254,7 +262,7 @@ onMounted(() => {
|
||||
<h3 class="section-subtitle">{{ selectedConfig.label }}</h3>
|
||||
<ul v-if="configRows.length" class="row-list">
|
||||
<li v-for="item in configRows" :key="item.id">
|
||||
<span>{{ item.name }}</span>
|
||||
<span>{{ item.name }}<span v-if="item.hasItemDrop" class="config-flag">有掉落物</span></span>
|
||||
<span class="row-actions">
|
||||
<button type="button" @click="editConfig(item)">编辑</button>
|
||||
<button type="button" @click="removeConfig(item.id)">删除</button>
|
||||
|
||||
@@ -118,12 +118,23 @@ onMounted(async () => {
|
||||
</DetailSection>
|
||||
|
||||
<DetailSection title="相关栖息地">
|
||||
<ul class="row-list">
|
||||
<ul v-if="item.relatedHabitats.length" class="row-list">
|
||||
<li v-for="habitat in item.relatedHabitats" :key="habitat.id">
|
||||
<RouterLink :to="`/habitats/${habitat.id}`">{{ habitat.name }}</RouterLink>
|
||||
<span>× {{ habitat.quantity }}</span>
|
||||
</li>
|
||||
</ul>
|
||||
<p v-else class="meta-line">无</p>
|
||||
</DetailSection>
|
||||
|
||||
<DetailSection title="Pokemon 掉落">
|
||||
<ul v-if="item.droppedByPokemon.length" class="row-list">
|
||||
<li v-for="entry in item.droppedByPokemon" :key="`${entry.pokemon.id}-${entry.skill.id}`">
|
||||
<RouterLink :to="`/pokemon/${entry.pokemon.id}`">#{{ entry.pokemon.id }} {{ entry.pokemon.name }}</RouterLink>
|
||||
<span>{{ entry.skill.name }}掉落物</span>
|
||||
</li>
|
||||
</ul>
|
||||
<p v-else class="meta-line">无</p>
|
||||
</DetailSection>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -74,6 +74,7 @@ const habitatRows = computed<HabitatRow[]>(() => {
|
||||
maps: [...row.maps].sort((a, b) => a.localeCompare(b))
|
||||
}));
|
||||
});
|
||||
const skillDropRows = computed(() => pokemon.value?.skills.filter((skill) => skill.itemDrop) ?? []);
|
||||
|
||||
onMounted(async () => {
|
||||
pokemon.value = await api.pokemonDetail(String(route.params.id));
|
||||
@@ -154,6 +155,15 @@ onMounted(async () => {
|
||||
<EntityChips :items="pokemon.skills" />
|
||||
</DetailSection>
|
||||
|
||||
<DetailSection v-if="skillDropRows.length" title="特长掉落物">
|
||||
<ul class="row-list skill-drop-summary">
|
||||
<li v-for="skill in skillDropRows" :key="skill.id">
|
||||
<span>{{ skill.name }}掉落物</span>
|
||||
<RouterLink v-if="skill.itemDrop" :to="`/items/${skill.itemDrop.id}`">{{ skill.itemDrop.name }}</RouterLink>
|
||||
</li>
|
||||
</ul>
|
||||
</DetailSection>
|
||||
|
||||
<DetailSection title="喜欢的东西">
|
||||
<EntityChips :items="pokemon.favorite_things" />
|
||||
</DetailSection>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user