Files
pokopiawiki.tootaio.com/frontend/src/views/RecipeEdit.vue
xiaomai 27100fbd22 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
2026-05-01 12:04:49 +08:00

211 lines
7.5 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 TagsSelect from '../components/TagsSelect.vue';
import { api, type ConfigType, type Item, type Options, type RecipePayload } from '../services/api';
const route = useRoute();
const router = useRouter();
const { t } = useI18n();
const options = ref<Options | null>(null);
const itemRows = ref<Item[]>([]);
const loading = ref(true);
const busy = ref(false);
const message = ref('');
const creatingSelect = ref('');
const recipeForm = ref({
itemId: '',
acquisitionMethodIds: [] as string[],
materials: [] as Array<{ itemId: string; quantity: number }>
});
const routeId = computed(() => (typeof route.params.id === 'string' ? route.params.id : ''));
const isEditing = computed(() => routeId.value !== '');
const materialItemOptions = computed(() => itemRows.value.map((item) => ({ id: item.id, name: item.name })));
const resultItemOptions = computed(() =>
itemRows.value
.filter((item) => !item.noRecipe || String(item.id) === recipeForm.value.itemId)
.map((item) => ({ id: item.id, name: item.name }))
);
const selectedItemName = computed(() => resultItemOptions.value.find((item) => String(item.id) === recipeForm.value.itemId)?.name ?? '');
const pageTitle = computed(() =>
isEditing.value
? t('pages.recipes.editTitle', { name: selectedItemName.value || t('pages.recipes.fallbackName') })
: t('pages.recipes.newTitle')
);
const cancelTo = computed(() => (isEditing.value ? `/recipes/${routeId.value}` : '/recipes'));
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 preselectedItemId() {
const itemId = route.query.itemId;
if (typeof itemId !== 'string') {
return '';
}
return resultItemOptions.value.some((item) => String(item.id) === itemId) ? itemId : '';
}
async function loadEditor() {
loading.value = true;
message.value = '';
try {
const [loadedOptions, loadedItems] = await Promise.all([api.options(), api.items({})]);
options.value = loadedOptions;
itemRows.value = loadedItems;
if (isEditing.value) {
const recipe = await api.recipeDetail(routeId.value);
recipeForm.value = {
itemId: String(recipe.item.id),
acquisitionMethodIds: recipe.acquisition_methods.map((method) => String(method.id)),
materials: recipe.materials.map((material) => ({ itemId: String(material.id), quantity: material.quantity }))
};
} else {
recipeForm.value.itemId = preselectedItemId();
}
} catch (error) {
message.value = errorText(error, t('errors.loadFailed'));
} finally {
loading.value = false;
}
}
function addRecipeMaterial() {
recipeForm.value.materials.push({ itemId: '', quantity: 1 });
}
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 saveRecipe() {
busy.value = true;
message.value = '';
try {
const payload: RecipePayload = {
itemId: Number(recipeForm.value.itemId),
acquisitionMethodIds: toIds(recipeForm.value.acquisitionMethodIds),
materials: toQuantityRows(recipeForm.value.materials)
};
const saved = isEditing.value ? await api.updateRecipe(routeId.value, payload) : await api.createRecipe(payload);
await router.push(`/recipes/${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.recipes.editSubtitle')">
<template #kicker>Recipe 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="saveRecipe">
<div class="field">
<label for="recipe-item">{{ t('pages.recipes.item') }}</label>
<TagsSelect
id="recipe-item"
v-model="recipeForm.itemId"
:options="resultItemOptions"
:multiple="false"
:placeholder="t('common.select')"
:search-placeholder="t('pages.pokemon.searchItems')"
/>
</div>
<div class="field">
<label for="recipe-methods">{{ t('pages.items.acquisitionMethods') }}</label>
<TagsSelect
id="recipe-methods"
v-model="recipeForm.acquisitionMethodIds"
:options="options.acquisitionMethods"
allow-create
:creating="creatingSelect === 'recipe-methods'"
:placeholder="t('pages.items.searchMethods')"
@create="createMultiOption('recipe-methods', 'acquisition-methods', $event, recipeForm.acquisitionMethodIds)"
/>
</div>
<div class="field">
<label>{{ t('pages.recipes.materials') }}</label>
<div v-for="(row, index) in recipeForm.materials" :key="index" class="inline-row">
<TagsSelect
:id="`recipe-material-${index}`"
v-model="row.itemId"
:options="materialItemOptions"
: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="recipeForm.materials.splice(index, 1)">{{ t('common.delete') }}</button>
</div>
<button type="button" class="plain-button" @click="addRecipeMaterial">{{ t('pages.recipes.addMaterial') }}</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.recipes.loadingEdit')">
<div v-for="index in 4" :key="index" class="field">
<Skeleton :width="index === 1 ? '52px' : '88px'" />
<Skeleton variant="box" height="44px" />
</div>
</section>
</section>
</template>