feat: enhance habitat appearances and item relations

Replace item tags with favorite things to unify entity tagging
Allow multiple maps, times, and weathers per habitat appearance
Make item usage optional and translate API error messages to Chinese
Add .dockerignore files for backend and frontend
This commit is contained in:
2026-04-30 06:34:23 +08:00
parent f6a40097c1
commit 7f36d6a916
11 changed files with 186 additions and 115 deletions

View File

@@ -4,6 +4,7 @@ import {
api,
type ConfigType,
type Habitat,
type HabitatDetail,
type HabitatPayload,
type Item,
type ItemPayload,
@@ -18,6 +19,13 @@ import {
type AdminTab = 'config' | 'pokemon' | 'items' | 'recipes' | 'habitats';
type EditableConfig = (NamedEntity | Skill) & { subcategory?: string | null };
type HabitatAppearanceForm = {
pokemonId: string;
mapIds: string[];
timeOfDays: string[];
weathers: string[];
rarity: number;
};
const tabs: Array<{ key: AdminTab; label: string }> = [
{ key: 'config', label: '系统配置' },
@@ -27,6 +35,9 @@ const tabs: Array<{ key: AdminTab; label: string }> = [
{ key: 'habitats', label: '栖息地' }
];
const timeOfDays = ['早晨', '中午', '傍晚', '晚上'];
const weathers = ['晴天', '阴天', '雨天'];
const configTypes: Array<{ key: ConfigType; label: string; hasSubcategory?: boolean }> = [
{ key: 'skills', label: '特长', hasSubcategory: true },
{ key: 'environments', label: '喜欢的环境' },
@@ -34,7 +45,6 @@ const configTypes: Array<{ key: ConfigType; label: string; hasSubcategory?: bool
{ key: 'item-categories', label: '物品 / 材料单分类' },
{ key: 'item-usages', label: '物品 / 材料单用途' },
{ key: 'acquisition-methods', label: '入手方式' },
{ key: 'item-tags', label: '物品标签' },
{ key: 'maps', label: '地图' }
];
@@ -73,7 +83,7 @@ const habitatForm = ref({
id: 0,
name: '',
recipeItems: [] as Array<{ itemId: string; quantity: number }>,
pokemonAppearances: [] as Array<{ pokemonId: string; mapId: string; timeOfDay: string; weather: string; rarity: number }>
pokemonAppearances: [] as HabitatAppearanceForm[]
});
const selectedConfig = computed(() => configTypes.find((item) => item.key === activeConfigType.value) ?? configTypes[0]);
@@ -93,8 +103,8 @@ async function run(action: () => Promise<void>) {
message.value = '';
try {
await action();
} catch {
message.value = '操作失败';
} catch (error) {
message.value = error instanceof Error && error.message ? error.message : '操作失败';
} finally {
busy.value = false;
}
@@ -235,7 +245,7 @@ async function editItem(item: Item) {
id: detail.id,
name: detail.name,
categoryId: String(detail.category.id),
usageId: String(detail.usage.id),
usageId: detail.usage ? String(detail.usage.id) : '',
recipeId: detail.recipe ? String(detail.recipe.id) : '',
dyeable: detail.customization.dyeable,
dualDyeable: detail.customization.dualDyeable,
@@ -251,7 +261,7 @@ async function saveItem() {
const payload: ItemPayload = {
name: itemForm.value.name,
categoryId: Number(itemForm.value.categoryId),
usageId: Number(itemForm.value.usageId),
usageId: itemForm.value.usageId ? Number(itemForm.value.usageId) : null,
recipeId: itemForm.value.recipeId ? Number(itemForm.value.recipeId) : null,
dyeable: itemForm.value.dyeable,
dualDyeable: itemForm.value.dualDyeable,
@@ -331,13 +341,36 @@ function addHabitatRecipeItem() {
function addPokemonAppearance() {
habitatForm.value.pokemonAppearances.push({
pokemonId: '',
mapId: '',
timeOfDay: '早晨',
weather: '晴天',
mapIds: [],
timeOfDays: ['早晨'],
weathers: ['晴天'],
rarity: 1
});
}
function groupPokemonAppearances(detail: HabitatDetail): HabitatAppearanceForm[] {
const rows = new Map<string, HabitatAppearanceForm>();
detail.pokemon.forEach((pokemon) => {
const key = `${pokemon.id}:${pokemon.rarity}`;
const row = rows.get(key) ?? {
pokemonId: String(pokemon.id),
mapIds: [],
timeOfDays: [],
weathers: [],
rarity: pokemon.rarity
};
const mapId = String(pokemon.map.id);
if (!row.mapIds.includes(mapId)) row.mapIds.push(mapId);
if (!row.timeOfDays.includes(pokemon.time_of_day)) row.timeOfDays.push(pokemon.time_of_day);
if (!row.weathers.includes(pokemon.weather)) row.weathers.push(pokemon.weather);
rows.set(key, row);
});
return [...rows.values()];
}
async function editHabitat(item: Habitat) {
await run(async () => {
const detail = await api.habitatDetail(item.id);
@@ -345,13 +378,7 @@ async function editHabitat(item: Habitat) {
id: detail.id,
name: detail.name,
recipeItems: detail.recipe.map((recipeItem) => ({ itemId: String(recipeItem.id), quantity: recipeItem.quantity })),
pokemonAppearances: detail.pokemon.map((pokemon) => ({
pokemonId: String(pokemon.id),
mapId: String(pokemon.map.id),
timeOfDay: pokemon.time_of_day,
weather: pokemon.weather,
rarity: pokemon.rarity
}))
pokemonAppearances: groupPokemonAppearances(detail)
};
});
}
@@ -364,12 +391,12 @@ async function saveHabitat() {
pokemonAppearances: habitatForm.value.pokemonAppearances
.map((item) => ({
pokemonId: Number(item.pokemonId),
mapId: Number(item.mapId),
timeOfDay: item.timeOfDay,
weather: item.weather,
mapIds: toIds(item.mapIds),
timeOfDays: item.timeOfDays.filter((entry) => timeOfDays.includes(entry)),
weathers: item.weathers.filter((entry) => weathers.includes(entry)),
rarity: Number(item.rarity)
}))
.filter((item) => item.pokemonId > 0 && item.mapId > 0)
.filter((item) => item.pokemonId > 0 && item.mapIds.length > 0 && item.timeOfDays.length > 0 && item.weathers.length > 0)
};
if (habitatForm.value.id) {
await api.updateHabitat(habitatForm.value.id, payload);
@@ -411,7 +438,7 @@ onMounted(() => {
<p v-if="message" class="status">{{ message }}</p>
<section v-if="activeTab === 'config'" class="admin-layout">
<div class="detail-section">
<form class="detail-section" @submit.prevent="saveConfig">
<h2>系统配置</h2>
<div class="field">
<label for="config-type">类型</label>
@@ -428,10 +455,10 @@ onMounted(() => {
<input id="config-subcategory" v-model="configForm.subcategory" />
</div>
<div class="form-actions">
<button type="button" class="link-button" :disabled="busy" @click="saveConfig">保存</button>
<button type="submit" class="link-button" :disabled="busy">保存</button>
<button type="button" class="plain-button" @click="resetConfigForm">新建</button>
</div>
</div>
</form>
<div class="detail-section">
<h2>{{ selectedConfig.label }}</h2>
@@ -507,7 +534,7 @@ onMounted(() => {
<div class="field">
<label for="item-usage">用途</label>
<select id="item-usage" v-model="itemForm.usageId">
<option value="">请选择</option>
<option value=""></option>
<option v-for="item in options.itemUsages" :key="item.id" :value="item.id">{{ item.name }}</option>
</select>
</div>
@@ -620,20 +647,14 @@ onMounted(() => {
<option value="">Pokemon</option>
<option v-for="item in pokemonRows" :key="item.id" :value="String(item.id)">#{{ item.id }} {{ item.name }}</option>
</select>
<select v-model="row.mapId">
<option value="">地图</option>
<select v-model="row.mapIds" multiple>
<option v-for="item in options.maps" :key="item.id" :value="String(item.id)">{{ item.name }}</option>
</select>
<select v-model="row.timeOfDay">
<option>早晨</option>
<option>中午</option>
<option>傍晚</option>
<option>晚上</option>
<select v-model="row.timeOfDays" multiple>
<option v-for="item in timeOfDays" :key="item">{{ item }}</option>
</select>
<select v-model="row.weather">
<option>晴天</option>
<option>阴天</option>
<option>雨天</option>
<select v-model="row.weathers" multiple>
<option v-for="item in weathers" :key="item">{{ item }}</option>
</select>
<input v-model.number="row.rarity" type="number" min="1" max="3" />
<button type="button" @click="habitatForm.pokemonAppearances.splice(index, 1)">删除</button>