feat(i18n): add full-stack internationalization support
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
This commit is contained in:
@@ -1,19 +1,23 @@
|
||||
<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 Pokemon,
|
||||
type TranslationMap
|
||||
} from '../services/api';
|
||||
|
||||
type HabitatAppearanceForm = {
|
||||
@@ -26,30 +30,46 @@ type HabitatAppearanceForm = {
|
||||
|
||||
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 = timeOfDays.map((value) => ({ value, label: value }));
|
||||
const weatherOptions = weathers.map((value) => ({ value, label: value }));
|
||||
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 ? `编辑 ${habitatForm.value.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[] {
|
||||
@@ -108,21 +128,28 @@ async function loadEditor() {
|
||||
message.value = '';
|
||||
|
||||
try {
|
||||
const [loadedOptions, loadedItems, loadedPokemon] = await Promise.all([api.options(), api.items({}), api.pokemon({})]);
|
||||
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, '加载失败');
|
||||
message.value = errorText(error, t('errors.loadFailed'));
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
@@ -146,7 +173,7 @@ async function createMultiOption(selectKey: string, type: ConfigType, name: stri
|
||||
values.push(value);
|
||||
}
|
||||
} catch (error) {
|
||||
message.value = errorText(error, '添加失败');
|
||||
message.value = errorText(error, t('errors.addFailed'));
|
||||
} finally {
|
||||
creatingSelect.value = '';
|
||||
}
|
||||
@@ -159,6 +186,7 @@ async function saveHabitat() {
|
||||
try {
|
||||
const payload: HabitatPayload = {
|
||||
name: habitatForm.value.name,
|
||||
translations: habitatForm.value.translations,
|
||||
recipeItems: toQuantityRows(habitatForm.value.recipeItems),
|
||||
pokemonAppearances: habitatForm.value.pokemonAppearances
|
||||
.map((item) => ({
|
||||
@@ -173,7 +201,7 @@ async function saveHabitat() {
|
||||
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, '保存失败');
|
||||
message.value = errorText(error, t('errors.saveFailed'));
|
||||
} finally {
|
||||
busy.value = false;
|
||||
}
|
||||
@@ -186,40 +214,45 @@ onMounted(() => {
|
||||
|
||||
<template>
|
||||
<section class="page-stack">
|
||||
<PageHeader :title="pageTitle" subtitle="维护栖息地配方和可能出现的 Pokemon。">
|
||||
<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">返回</RouterLink>
|
||||
<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">
|
||||
<div class="field">
|
||||
<label for="habitat-name">名称</label>
|
||||
<input id="habitat-name" v-model="habitatForm.name" required />
|
||||
</div>
|
||||
<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>配方</label>
|
||||
<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="请选择"
|
||||
search-placeholder="搜索物品"
|
||||
:placeholder="t('common.select')"
|
||||
:search-placeholder="t('pages.pokemon.searchItems')"
|
||||
/>
|
||||
<input v-model.number="row.quantity" aria-label="数量" type="number" min="1" />
|
||||
<button type="button" @click="habitatForm.recipeItems.splice(index, 1)">删除</button>
|
||||
<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">添加物品</button>
|
||||
<button type="button" class="plain-button" @click="addHabitatRecipeItem">{{ t('pages.habitats.addItem') }}</button>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label>可出现的 Pokemon</label>
|
||||
<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">
|
||||
@@ -230,43 +263,45 @@ onMounted(() => {
|
||||
:options="pokemonSelectOptions"
|
||||
:multiple="false"
|
||||
placeholder="Pokemon"
|
||||
search-placeholder="搜索 Pokemon"
|
||||
:search-placeholder="t('pages.pokemon.searchPokemon')"
|
||||
/>
|
||||
</div>
|
||||
<SwitchGroup :id="`appearance-times-${index}`" v-model="row.timeOfDays" label="时间" :options="timeOfDayOptions" />
|
||||
<SwitchGroup :id="`appearance-weathers-${index}`" v-model="row.weathers" label="天气" :options="weatherOptions" />
|
||||
<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}`">稀有度</label>
|
||||
<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)">删除</button>
|
||||
<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}`">地图</label>
|
||||
<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="搜索地图"
|
||||
:placeholder="t('pages.habitats.searchMaps')"
|
||||
@create="createMultiOption(`appearance-maps-${index}`, 'maps', $event, row.mapIds)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="plain-button" @click="addPokemonAppearance">添加 Pokemon</button>
|
||||
<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 ? '保存中' : '保存' }}</button>
|
||||
<RouterLink class="plain-button" :to="cancelTo">取消</RouterLink>
|
||||
<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="正在加载栖息地编辑内容">
|
||||
<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" />
|
||||
|
||||
Reference in New Issue
Block a user