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:
@@ -48,7 +48,7 @@ export interface Item {
|
||||
id: number;
|
||||
name: string;
|
||||
category: NamedEntity;
|
||||
usage: NamedEntity;
|
||||
usage: NamedEntity | null;
|
||||
customization: {
|
||||
dyeable: boolean;
|
||||
dualDyeable: boolean;
|
||||
@@ -91,7 +91,6 @@ export type ConfigType =
|
||||
| 'item-categories'
|
||||
| 'item-usages'
|
||||
| 'acquisition-methods'
|
||||
| 'item-tags'
|
||||
| 'maps';
|
||||
|
||||
export interface PokemonPayload {
|
||||
@@ -105,7 +104,7 @@ export interface PokemonPayload {
|
||||
export interface ItemPayload {
|
||||
name: string;
|
||||
categoryId: number;
|
||||
usageId: number;
|
||||
usageId: number | null;
|
||||
recipeId: number | null;
|
||||
dyeable: boolean;
|
||||
dualDyeable: boolean;
|
||||
@@ -125,9 +124,9 @@ export interface HabitatPayload {
|
||||
recipeItems: Array<{ itemId: number; quantity: number }>;
|
||||
pokemonAppearances: Array<{
|
||||
pokemonId: number;
|
||||
mapId: number;
|
||||
timeOfDay: string;
|
||||
weather: string;
|
||||
mapIds: number[];
|
||||
timeOfDays: string[];
|
||||
weathers: string[];
|
||||
rarity: number;
|
||||
}>;
|
||||
}
|
||||
@@ -145,11 +144,24 @@ export function buildQuery(params: Record<string, string | number | undefined>):
|
||||
return query ? `?${query}` : '';
|
||||
}
|
||||
|
||||
async function getErrorMessage(response: Response): Promise<string> {
|
||||
try {
|
||||
const data = (await response.json()) as { message?: unknown };
|
||||
if (typeof data.message === 'string' && data.message.trim() !== '') {
|
||||
return data.message;
|
||||
}
|
||||
} catch {
|
||||
// Ignore invalid or empty error bodies and use the status fallback.
|
||||
}
|
||||
|
||||
return `请求失败(${response.status})`;
|
||||
}
|
||||
|
||||
async function getJson<T>(path: string): Promise<T> {
|
||||
const response = await fetch(`${apiBaseUrl}${path}`);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Request failed with ${response.status}`);
|
||||
throw new Error(await getErrorMessage(response));
|
||||
}
|
||||
|
||||
return response.json() as Promise<T>;
|
||||
@@ -165,7 +177,7 @@ async function sendJson<T>(path: string, method: 'POST' | 'PUT', body: unknown):
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Request failed with ${response.status}`);
|
||||
throw new Error(await getErrorMessage(response));
|
||||
}
|
||||
|
||||
return response.json() as Promise<T>;
|
||||
@@ -177,7 +189,7 @@ async function deleteJson(path: string): Promise<void> {
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Request failed with ${response.status}`);
|
||||
throw new Error(await getErrorMessage(response));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -32,7 +32,7 @@ onMounted(async () => {
|
||||
<section class="detail-section">
|
||||
<h2>可能出现的宝可梦</h2>
|
||||
<ul class="row-list">
|
||||
<li v-for="item in habitat.pokemon" :key="`${item.id}-${item.map.id}-${item.time_of_day}`">
|
||||
<li v-for="item in habitat.pokemon" :key="`${item.id}-${item.map.id}-${item.time_of_day}-${item.weather}`">
|
||||
<RouterLink :to="`/pokemon/${item.id}`">{{ item.name }}</RouterLink>
|
||||
<span>{{ item.time_of_day }} · {{ item.weather }} · {{ item.rarity }} 星 · {{ item.map.name }}</span>
|
||||
</li>
|
||||
|
||||
@@ -30,7 +30,7 @@ onMounted(async () => {
|
||||
<div class="page-header">
|
||||
<div>
|
||||
<h1 class="page-title">{{ item.name }}</h1>
|
||||
<p class="page-subtitle">{{ item.category.name }} · {{ item.usage.name }}</p>
|
||||
<p class="page-subtitle">{{ item.usage ? `${item.category.name} · ${item.usage.name}` : item.category.name }}</p>
|
||||
</div>
|
||||
<RouterLink class="link-button" to="/items">返回列表</RouterLink>
|
||||
</div>
|
||||
|
||||
@@ -86,7 +86,7 @@ watch([tab, itemQuery], loadItems);
|
||||
<div v-else-if="tab === 'items'" class="grid">
|
||||
<RouterLink v-for="item in items" :key="item.id" class="entity-card" :to="`/items/${item.id}`">
|
||||
<h2>{{ item.name }}</h2>
|
||||
<p class="meta-line">{{ item.category.name }} · {{ item.usage.name }}</p>
|
||||
<p class="meta-line">{{ item.usage ? `${item.category.name} · ${item.usage.name}` : item.category.name }}</p>
|
||||
<EntityChips :items="item.tags" />
|
||||
</RouterLink>
|
||||
</div>
|
||||
|
||||
@@ -37,7 +37,7 @@ onMounted(async () => {
|
||||
<section class="detail-section">
|
||||
<h2>栖息地</h2>
|
||||
<ul class="row-list">
|
||||
<li v-for="habitat in pokemon.habitats" :key="`${habitat.id}-${habitat.map.id}-${habitat.time_of_day}`">
|
||||
<li v-for="habitat in pokemon.habitats" :key="`${habitat.id}-${habitat.map.id}-${habitat.time_of_day}-${habitat.weather}`">
|
||||
<RouterLink :to="`/habitats/${habitat.id}`">{{ habitat.name }}</RouterLink>
|
||||
<span>{{ habitat.time_of_day }} · {{ habitat.weather }} · {{ habitat.rarity }} 星 · {{ habitat.map.name }}</span>
|
||||
</li>
|
||||
|
||||
Reference in New Issue
Block a user