Add languages and entity_translations tables to database schema Implement localized queries and translation management in backend Integrate frontend i18n and add translation UI components
312 lines
11 KiB
Vue
312 lines
11 KiB
Vue
<script setup lang="ts">
|
|
import { computed, onMounted, ref } from 'vue';
|
|
import { useI18n } from 'vue-i18n';
|
|
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 SwitchGroup from '../components/SwitchGroup.vue';
|
|
import TagsSelect from '../components/TagsSelect.vue';
|
|
import TranslationFields from '../components/TranslationFields.vue';
|
|
import {
|
|
api,
|
|
type ConfigType,
|
|
type HabitatDetail,
|
|
type HabitatPayload,
|
|
type Item,
|
|
type Language,
|
|
type Options,
|
|
type Pokemon,
|
|
type TranslationMap
|
|
} from '../services/api';
|
|
|
|
type HabitatAppearanceForm = {
|
|
pokemonId: string;
|
|
mapIds: string[];
|
|
timeOfDays: string[];
|
|
weathers: string[];
|
|
rarity: number;
|
|
};
|
|
|
|
const route = useRoute();
|
|
const router = useRouter();
|
|
const { t } = useI18n();
|
|
const options = ref<Options | null>(null);
|
|
const itemRows = ref<Item[]>([]);
|
|
const pokemonRows = ref<Pokemon[]>([]);
|
|
const languages = ref<Language[]>([]);
|
|
const loading = ref(true);
|
|
const busy = ref(false);
|
|
const message = ref('');
|
|
const creatingSelect = ref('');
|
|
const habitatForm = ref({
|
|
name: '',
|
|
translations: {} as TranslationMap,
|
|
recipeItems: [] as Array<{ itemId: string; quantity: number }>,
|
|
pokemonAppearances: [] as HabitatAppearanceForm[]
|
|
});
|
|
|
|
const timeOfDays = ['早晨', '中午', '傍晚', '晚上'];
|
|
const weathers = ['晴天', '阴天', '雨天'];
|
|
const timeOfDayOptions = computed(() => [
|
|
{ value: '早晨', label: t('appearance.morning') },
|
|
{ value: '中午', label: t('appearance.noon') },
|
|
{ value: '傍晚', label: t('appearance.evening') },
|
|
{ value: '晚上', label: t('appearance.night') }
|
|
]);
|
|
const weatherOptions = computed(() => [
|
|
{ value: '晴天', label: t('appearance.sunny') },
|
|
{ value: '阴天', label: t('appearance.cloudy') },
|
|
{ value: '雨天', label: t('appearance.rainy') }
|
|
]);
|
|
const routeId = computed(() => (typeof route.params.id === 'string' ? route.params.id : ''));
|
|
const isEditing = computed(() => routeId.value !== '');
|
|
const itemSelectOptions = computed(() => itemRows.value.map((item) => ({ id: item.id, name: item.name })));
|
|
const pokemonSelectOptions = computed(() =>
|
|
pokemonRows.value.map((pokemon) => ({ id: pokemon.id, name: pokemon.name, label: `#${pokemon.id} ${pokemon.name}` }))
|
|
);
|
|
const pageTitle = computed(() =>
|
|
isEditing.value
|
|
? t('pages.habitats.editTitle', { name: habitatForm.value.name || t('pages.habitats.fallbackName') })
|
|
: t('pages.habitats.newTitle')
|
|
);
|
|
const cancelTo = computed(() => (isEditing.value ? `/habitats/${routeId.value}` : '/habitats'));
|
|
|
|
function toIds(values: string[]): number[] {
|
|
return values.map(Number).filter((item) => Number.isInteger(item) && item > 0);
|
|
}
|
|
|
|
function toQuantityRows(rows: Array<{ itemId: string; quantity: number }>) {
|
|
return rows
|
|
.map((item) => ({ itemId: Number(item.itemId), quantity: Number(item.quantity) }))
|
|
.filter((item) => Number.isInteger(item.itemId) && item.itemId > 0 && Number.isInteger(item.quantity) && item.quantity > 0);
|
|
}
|
|
|
|
function errorText(error: unknown, fallback: string) {
|
|
return error instanceof Error && error.message ? error.message : fallback;
|
|
}
|
|
|
|
function addHabitatRecipeItem() {
|
|
habitatForm.value.recipeItems.push({ itemId: '', quantity: 1 });
|
|
}
|
|
|
|
function addPokemonAppearance() {
|
|
habitatForm.value.pokemonAppearances.push({
|
|
pokemonId: '',
|
|
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 loadEditor() {
|
|
loading.value = true;
|
|
message.value = '';
|
|
|
|
try {
|
|
const [loadedOptions, loadedItems, loadedPokemon, loadedLanguages] = await Promise.all([
|
|
api.options(),
|
|
api.items({}),
|
|
api.pokemon({}),
|
|
api.languages()
|
|
]);
|
|
options.value = loadedOptions;
|
|
itemRows.value = loadedItems;
|
|
pokemonRows.value = loadedPokemon;
|
|
languages.value = loadedLanguages;
|
|
|
|
if (isEditing.value) {
|
|
const habitat = await api.habitatDetail(routeId.value);
|
|
habitatForm.value = {
|
|
name: habitat.name,
|
|
translations: habitat.translations ?? {},
|
|
recipeItems: habitat.recipe.map((recipeItem) => ({ itemId: String(recipeItem.id), quantity: recipeItem.quantity })),
|
|
pokemonAppearances: groupPokemonAppearances(habitat)
|
|
};
|
|
}
|
|
} catch (error) {
|
|
message.value = errorText(error, t('errors.loadFailed'));
|
|
} finally {
|
|
loading.value = false;
|
|
}
|
|
}
|
|
|
|
async function loadOptions() {
|
|
options.value = await api.options();
|
|
}
|
|
|
|
async function createMultiOption(selectKey: string, type: ConfigType, name: string, values: string[]) {
|
|
const cleanName = name.trim();
|
|
if (!cleanName) return;
|
|
|
|
creatingSelect.value = selectKey;
|
|
message.value = '';
|
|
try {
|
|
const created = await api.createConfig(type, { name: cleanName });
|
|
await loadOptions();
|
|
const value = String(created.id);
|
|
if (!values.includes(value)) {
|
|
values.push(value);
|
|
}
|
|
} catch (error) {
|
|
message.value = errorText(error, t('errors.addFailed'));
|
|
} finally {
|
|
creatingSelect.value = '';
|
|
}
|
|
}
|
|
|
|
async function saveHabitat() {
|
|
busy.value = true;
|
|
message.value = '';
|
|
|
|
try {
|
|
const payload: HabitatPayload = {
|
|
name: habitatForm.value.name,
|
|
translations: habitatForm.value.translations,
|
|
recipeItems: toQuantityRows(habitatForm.value.recipeItems),
|
|
pokemonAppearances: habitatForm.value.pokemonAppearances
|
|
.map((item) => ({
|
|
pokemonId: Number(item.pokemonId),
|
|
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.mapIds.length > 0 && item.timeOfDays.length > 0 && item.weathers.length > 0)
|
|
};
|
|
const saved = isEditing.value ? await api.updateHabitat(routeId.value, payload) : await api.createHabitat(payload);
|
|
await router.push(`/habitats/${saved.id}`);
|
|
} catch (error) {
|
|
message.value = errorText(error, t('errors.saveFailed'));
|
|
} finally {
|
|
busy.value = false;
|
|
}
|
|
}
|
|
|
|
onMounted(() => {
|
|
void loadEditor();
|
|
});
|
|
</script>
|
|
|
|
<template>
|
|
<section class="page-stack">
|
|
<PageHeader :title="pageTitle" :subtitle="t('pages.habitats.editSubtitle')">
|
|
<template #kicker>Habitat Edit</template>
|
|
<template #actions>
|
|
<RouterLink class="ui-button ui-button--blue ui-button--small" :to="cancelTo">{{ t('common.back') }}</RouterLink>
|
|
</template>
|
|
</PageHeader>
|
|
|
|
<StatusMessage v-if="message" variant="danger">{{ message }}</StatusMessage>
|
|
|
|
<form v-if="!loading && options" class="detail-section" @submit.prevent="saveHabitat">
|
|
<TranslationFields
|
|
id-prefix="habitat-name"
|
|
v-model:base-value="habitatForm.name"
|
|
v-model:translations="habitatForm.translations"
|
|
field="name"
|
|
:label="t('common.name')"
|
|
:languages="languages"
|
|
required
|
|
/>
|
|
|
|
<div class="field">
|
|
<label>{{ t('pages.habitats.recipe') }}</label>
|
|
<div v-for="(row, index) in habitatForm.recipeItems" :key="index" class="inline-row">
|
|
<TagsSelect
|
|
:id="`habitat-recipe-item-${index}`"
|
|
v-model="row.itemId"
|
|
:options="itemSelectOptions"
|
|
:multiple="false"
|
|
:placeholder="t('common.select')"
|
|
:search-placeholder="t('pages.pokemon.searchItems')"
|
|
/>
|
|
<input v-model.number="row.quantity" :aria-label="t('common.quantity')" type="number" min="1" />
|
|
<button type="button" @click="habitatForm.recipeItems.splice(index, 1)">{{ t('common.delete') }}</button>
|
|
</div>
|
|
<button type="button" class="plain-button" @click="addHabitatRecipeItem">{{ t('pages.habitats.addItem') }}</button>
|
|
</div>
|
|
|
|
<div class="field">
|
|
<label>{{ t('pages.habitats.possiblePokemon') }}</label>
|
|
<div v-for="(row, index) in habitatForm.pokemonAppearances" :key="index" class="appearance-row">
|
|
<div class="appearance-row__main">
|
|
<div class="field appearance-row__pokemon">
|
|
<label :for="`appearance-pokemon-${index}`">Pokemon</label>
|
|
<TagsSelect
|
|
:id="`appearance-pokemon-${index}`"
|
|
v-model="row.pokemonId"
|
|
:options="pokemonSelectOptions"
|
|
:multiple="false"
|
|
placeholder="Pokemon"
|
|
:search-placeholder="t('pages.pokemon.searchPokemon')"
|
|
/>
|
|
</div>
|
|
<SwitchGroup :id="`appearance-times-${index}`" v-model="row.timeOfDays" :label="t('appearance.time')" :options="timeOfDayOptions" />
|
|
<SwitchGroup :id="`appearance-weathers-${index}`" v-model="row.weathers" :label="t('appearance.weather')" :options="weatherOptions" />
|
|
|
|
<div class="field appearance-row__rarity">
|
|
<label :for="`appearance-rarity-${index}`">{{ t('appearance.rarity') }}</label>
|
|
<input :id="`appearance-rarity-${index}`" v-model.number="row.rarity" type="number" min="1" max="3" />
|
|
</div>
|
|
|
|
<button type="button" class="appearance-row__delete" @click="habitatForm.pokemonAppearances.splice(index, 1)">
|
|
{{ t('common.delete') }}
|
|
</button>
|
|
</div>
|
|
|
|
<div class="field appearance-row__maps">
|
|
<label :for="`appearance-maps-${index}`">{{ t('appearance.map') }}</label>
|
|
<TagsSelect
|
|
:id="`appearance-maps-${index}`"
|
|
v-model="row.mapIds"
|
|
:options="options.maps"
|
|
allow-create
|
|
:creating="creatingSelect === `appearance-maps-${index}`"
|
|
:placeholder="t('pages.habitats.searchMaps')"
|
|
@create="createMultiOption(`appearance-maps-${index}`, 'maps', $event, row.mapIds)"
|
|
/>
|
|
</div>
|
|
</div>
|
|
<button type="button" class="plain-button" @click="addPokemonAppearance">{{ t('pages.habitats.addPokemon') }}</button>
|
|
</div>
|
|
|
|
<div class="form-actions">
|
|
<button type="submit" class="link-button" :disabled="busy">{{ busy ? t('common.saving') : t('common.save') }}</button>
|
|
<RouterLink class="plain-button" :to="cancelTo">{{ t('common.cancel') }}</RouterLink>
|
|
</div>
|
|
</form>
|
|
|
|
<section v-else class="detail-section skeleton-detail-section" aria-busy="true" :aria-label="t('pages.habitats.loadingEdit')">
|
|
<div v-for="index in 5" :key="index" class="field">
|
|
<Skeleton :width="index === 1 ? '52px' : '112px'" />
|
|
<Skeleton variant="box" height="44px" />
|
|
</div>
|
|
</section>
|
|
</section>
|
|
</template>
|