refactor(pokemon): reorganize edit form fields into tabs

Split form into Basic and Advance tabs to improve usability
Add validation logic to switch tabs if required fields are missing
This commit is contained in:
2026-05-02 09:32:08 +08:00
parent c2f58fe661
commit 7ee25e2437
4 changed files with 286 additions and 157 deletions

View File

@@ -98,6 +98,9 @@ const messages = {
detailKicker: 'Pokédex Detail',
editKicker: 'Pokédex Edit',
editSubtitle: 'Maintain Pokemon profile, details, types, stats, specialities, and favourites.',
editSections: 'Pokemon edit sections',
editTabBasic: 'Basic',
editTabAdvance: 'Advance',
newTitle: 'New Pokemon',
editTitle: 'Edit #{id} {name}',
loadingList: 'Loading Pokemon list',
@@ -529,6 +532,9 @@ const messages = {
detailKicker: 'Pokédex Detail',
editKicker: 'Pokédex Edit',
editSubtitle: '维护 Pokemon 介绍、属性、六维、特长和喜欢的东西。',
editSections: 'Pokemon 编辑分区',
editTabBasic: '基础',
editTabAdvance: '进阶',
newTitle: '新增 Pokemon',
editTitle: '编辑 #{id} {name}',
loadingList: '正在加载 Pokemon 列表',

View File

@@ -725,6 +725,69 @@ button:disabled,
gap: 12px;
}
.modal-edit-form--tabbed {
gap: 14px;
}
.pokemon-edit-form {
height: clamp(420px, calc(100dvh - 188px), 640px);
min-height: 0;
grid-template-rows: auto minmax(0, 1fr);
}
.pokemon-edit-panel {
min-height: 0;
display: grid;
gap: 12px;
align-content: start;
overflow-y: auto;
padding-right: 2px;
}
.pokemon-edit-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
align-items: start;
}
.pokemon-measurement-row {
display: grid;
grid-template-columns: minmax(0, 1.25fr) minmax(0, 1fr);
gap: 12px;
align-items: stretch;
}
.pokemon-measurement-control {
min-width: 0;
display: flex;
flex-wrap: wrap;
align-items: flex-end;
gap: 8px;
padding: 12px;
border: 1px solid var(--line);
border-radius: var(--radius-card);
background: var(--surface-soft);
}
.pokemon-measurement-control > .field-label {
min-height: 44px;
display: inline-flex;
align-items: center;
}
.pokemon-measurement-control > .field {
min-width: 96px;
flex: 1 1 110px;
}
.pokemon-measurement-control > .pokemon-measurement-fields {
min-width: 0;
flex: 1 1 220px;
grid-template-columns: repeat(2, minmax(72px, 1fr));
gap: 8px;
}
.pokemon-measurement-fields,
.pokemon-stats-fields {
display: grid;
@@ -3409,6 +3472,10 @@ button:disabled,
.appearance-row__main {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.pokemon-measurement-row {
grid-template-columns: 1fr;
}
}
@media (max-width: 640px) {
@@ -3436,6 +3503,7 @@ button:disabled,
.toolbar,
.entity-grid,
.grid,
.pokemon-edit-grid,
.coming-soon-preview {
grid-template-columns: 1fr;
}

View File

@@ -1,12 +1,13 @@
<script setup lang="ts">
import { Icon } from '@iconify/vue';
import { computed, onMounted, ref, watch } from 'vue';
import { computed, nextTick, onMounted, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRoute, useRouter } from 'vue-router';
import Modal from '../components/Modal.vue';
import PokemonStatsFields from '../components/PokemonStatsFields.vue';
import Skeleton from '../components/Skeleton.vue';
import StatusMessage from '../components/StatusMessage.vue';
import Tabs from '../components/Tabs.vue';
import TagsSelect from '../components/TagsSelect.vue';
import TranslationFields from '../components/TranslationFields.vue';
import { iconCancel, iconSave } from '../icons';
@@ -36,6 +37,7 @@ const loading = ref(true);
const busy = ref(false);
const message = ref('');
const creatingSelect = ref('');
const activeEditTab = ref('basic');
const heightUnit = ref<'imperial' | 'metric'>('imperial');
const weightUnit = ref<'imperial' | 'metric'>('imperial');
@@ -77,6 +79,10 @@ const cancelTo = computed(() => (isEditing.value ? `/pokemon/${routeId.value}` :
const selectedSkillDropRows = computed(() =>
pokemonForm.value.skillItemDrops.filter((row) => pokemonForm.value.skillIds.includes(row.skillId) && skillSupportsItemDrop(row.skillId))
);
const editTabs = computed(() => [
{ value: 'basic', label: t('pages.pokemon.editTabBasic') },
{ value: 'advance', label: t('pages.pokemon.editTabAdvance') }
]);
const totalHeightInchesValue = computed(() => Math.round(pokemonForm.value.heightInches));
const heightFeetValue = computed(() => Math.floor(totalHeightInchesValue.value / 12));
const heightInchesValue = computed(() => totalHeightInchesValue.value - heightFeetValue.value * 12);
@@ -164,6 +170,21 @@ function pokemonNameForSave() {
return pokemonForm.value.translations[String(locale.value || '')]?.name ?? '';
}
function pokemonIdForSave() {
return Number(isEditing.value ? routeId.value : pokemonForm.value.id);
}
function hasRequiredBasicFields() {
const id = pokemonIdForSave();
return Number.isInteger(id) && id > 0 && pokemonNameForSave().trim() !== '';
}
async function showBasicFieldValidation() {
activeEditTab.value = 'basic';
await nextTick();
document.querySelector<HTMLFormElement>('#pokemon-edit-form')?.reportValidity();
}
function closeEditor() {
void router.push(cancelTo.value);
}
@@ -241,12 +262,17 @@ async function createMultiOption(selectKey: string, type: ConfigType, name: stri
}
async function savePokemon() {
if (!hasRequiredBasicFields()) {
await showBasicFieldValidation();
return;
}
busy.value = true;
message.value = '';
try {
const payload: PokemonPayload = {
id: Number(isEditing.value ? routeId.value : pokemonForm.value.id),
id: pokemonIdForSave(),
name: pokemonNameForSave(),
genus: pokemonForm.value.genus,
details: pokemonForm.value.details,
@@ -282,171 +308,186 @@ watch(() => pokemonForm.value.skillIds.slice(), syncSkillItemDrops);
<Modal :title="pageTitle" :subtitle="t('pages.pokemon.editSubtitle')" :close-label="t('common.close')" size="wide" @close="closeEditor">
<StatusMessage v-if="message" variant="danger">{{ message }}</StatusMessage>
<form v-if="!loading && options" id="pokemon-edit-form" class="modal-edit-form" @submit.prevent="savePokemon">
<div class="field">
<label for="pokemon-id">ID</label>
<input id="pokemon-id" v-model="pokemonForm.id" :disabled="isEditing" min="1" required type="number" />
</div>
<form v-if="!loading && options" id="pokemon-edit-form" class="modal-edit-form modal-edit-form--tabbed pokemon-edit-form" @submit.prevent="savePokemon">
<Tabs id="pokemon-edit-tabs" v-model="activeEditTab" :tabs="editTabs" :label="t('pages.pokemon.editSections')" />
<TranslationFields
id-prefix="pokemon-name"
v-model:base-value="pokemonForm.name"
v-model:translations="pokemonForm.translations"
field="name"
:label="t('common.name')"
:languages="languages"
required
/>
<TranslationFields
id-prefix="pokemon-genus"
v-model:base-value="pokemonForm.genus"
v-model:translations="pokemonForm.translations"
field="genus"
:label="t('pages.pokemon.genus')"
:languages="languages"
/>
<TranslationFields
id-prefix="pokemon-details"
v-model:base-value="pokemonForm.details"
v-model:translations="pokemonForm.translations"
field="details"
:label="t('pages.pokemon.details')"
:languages="languages"
multiline
:rows="5"
/>
<div class="field">
<span id="pokemon-height-label" class="field-label">{{ t('pages.pokemon.height') }}</span>
<div class="segmented" aria-labelledby="pokemon-height-label">
<button :class="{ active: heightUnit === 'imperial' }" type="button" @click="heightUnit = 'imperial'">
{{ t('pages.pokemon.heightImperial') }}
</button>
<button :class="{ active: heightUnit === 'metric' }" type="button" @click="heightUnit = 'metric'">
{{ t('pages.pokemon.heightMetric') }}
</button>
</div>
<div v-if="heightUnit === 'imperial'" class="pokemon-measurement-fields">
<section v-if="activeEditTab === 'basic'" class="pokemon-edit-panel" role="tabpanel" :aria-label="t('pages.pokemon.editTabBasic')">
<div class="pokemon-edit-grid">
<div class="field">
<label for="pokemon-height-feet">{{ t('pages.pokemon.feet') }}</label>
<input id="pokemon-height-feet" :value="heightFeetValue" min="0" step="1" type="number" inputmode="numeric" @input="updateHeightFeet" />
<label for="pokemon-id">ID</label>
<input id="pokemon-id" v-model="pokemonForm.id" :disabled="isEditing" min="1" required type="number" />
</div>
<TranslationFields
id-prefix="pokemon-name"
v-model:base-value="pokemonForm.name"
v-model:translations="pokemonForm.translations"
field="name"
:label="t('common.name')"
:languages="languages"
required
/>
</div>
<div class="pokemon-edit-grid">
<div class="field">
<label for="pokemon-height-inches">{{ t('pages.pokemon.inches') }}</label>
<input id="pokemon-height-inches" :value="heightInchesValue" min="0" step="1" type="number" inputmode="numeric" @input="updateHeightInches" />
</div>
</div>
<div v-else class="field">
<label for="pokemon-height-meters">{{ t('pages.pokemon.meters') }}</label>
<input id="pokemon-height-meters" :value="heightMetersValue" min="0" step="0.01" type="number" inputmode="decimal" @input="updateHeightMeters" />
</div>
</div>
<div class="field">
<span id="pokemon-weight-label" class="field-label">{{ t('pages.pokemon.weight') }}</span>
<div class="segmented" aria-labelledby="pokemon-weight-label">
<button :class="{ active: weightUnit === 'imperial' }" type="button" @click="weightUnit = 'imperial'">
{{ t('pages.pokemon.pounds') }}
</button>
<button :class="{ active: weightUnit === 'metric' }" type="button" @click="weightUnit = 'metric'">
{{ t('pages.pokemon.kilograms') }}
</button>
</div>
<div v-if="weightUnit === 'imperial'" class="field">
<label for="pokemon-weight-pounds">{{ t('pages.pokemon.pounds') }}</label>
<input id="pokemon-weight-pounds" :value="weightPoundsValue" min="0" step="0.1" type="number" inputmode="decimal" @input="updateWeightPounds" />
</div>
<div v-else class="field">
<label for="pokemon-weight-kg">{{ t('pages.pokemon.kilograms') }}</label>
<input id="pokemon-weight-kg" :value="weightKgValue" min="0" step="0.01" type="number" inputmode="decimal" @input="updateWeightKg" />
</div>
</div>
<div class="field">
<label for="pokemon-types">{{ t('pages.pokemon.types') }}</label>
<TagsSelect
id="pokemon-types"
v-model="pokemonForm.typeIds"
:options="options.pokemonTypes"
:max="2"
allow-create
:creating="creatingSelect === 'pokemon-types'"
:placeholder="t('pages.pokemon.searchTypes')"
@create="createMultiOption('pokemon-types', 'pokemon-types', $event, pokemonForm.typeIds, 2)"
/>
</div>
<div class="field">
<span class="field-label">{{ t('pages.pokemon.statsTitle') }}</span>
<PokemonStatsFields id-prefix="pokemon-stats" v-model="pokemonForm.stats" />
</div>
<div class="field">
<label for="pokemon-environment">{{ t('pages.pokemon.environment') }}</label>
<TagsSelect
id="pokemon-environment"
v-model="pokemonForm.environmentId"
:options="options.environments"
:multiple="false"
allow-create
:creating="creatingSelect === 'pokemon-environment'"
:placeholder="t('common.select')"
:search-placeholder="t('pages.pokemon.searchEnvironment')"
@create="createSingleOption('pokemon-environment', 'environments', $event, (value) => (pokemonForm.environmentId = value))"
/>
</div>
<div class="field">
<label for="pokemon-skills">{{ t('pages.pokemon.skills') }}</label>
<TagsSelect
id="pokemon-skills"
v-model="pokemonForm.skillIds"
:options="options.skills"
:max="2"
allow-create
:creating="creatingSelect === 'pokemon-skills'"
:placeholder="t('pages.pokemon.searchSkills')"
@create="createMultiOption('pokemon-skills', 'skills', $event, pokemonForm.skillIds, 2)"
/>
</div>
<div class="field">
<label for="pokemon-things">{{ t('pages.pokemon.favoriteThings') }}</label>
<TagsSelect
id="pokemon-things"
v-model="pokemonForm.favoriteThingIds"
:options="options.favoriteThings"
:max="6"
allow-create
:creating="creatingSelect === 'pokemon-things'"
:placeholder="t('pages.pokemon.searchFavoriteThings')"
@create="createMultiOption('pokemon-things', 'favorite-things', $event, pokemonForm.favoriteThingIds, 6)"
/>
</div>
<div v-if="selectedSkillDropRows.length" class="field">
<span class="field-label">{{ t('pages.pokemon.skillDrops') }}</span>
<div class="skill-drop-list">
<div v-for="row in selectedSkillDropRows" :key="row.skillId" class="skill-drop-row">
<label :for="`pokemon-skill-drops-${row.skillId}`">{{ skillDropLabel(row.skillId) }}</label>
<label for="pokemon-environment">{{ t('pages.pokemon.environment') }}</label>
<TagsSelect
:id="`pokemon-skill-drops-${row.skillId}`"
v-model="row.itemId"
:options="itemOptions"
id="pokemon-environment"
v-model="pokemonForm.environmentId"
:options="options.environments"
:multiple="false"
:placeholder="t('pages.pokemon.dropItem')"
:search-placeholder="t('pages.pokemon.searchItems')"
allow-create
:creating="creatingSelect === 'pokemon-environment'"
:placeholder="t('common.select')"
:search-placeholder="t('pages.pokemon.searchEnvironment')"
@create="createSingleOption('pokemon-environment', 'environments', $event, (value) => (pokemonForm.environmentId = value))"
/>
</div>
<div class="field">
<label for="pokemon-skills">{{ t('pages.pokemon.skills') }}</label>
<TagsSelect
id="pokemon-skills"
v-model="pokemonForm.skillIds"
:options="options.skills"
:max="2"
allow-create
:creating="creatingSelect === 'pokemon-skills'"
:placeholder="t('pages.pokemon.searchSkills')"
@create="createMultiOption('pokemon-skills', 'skills', $event, pokemonForm.skillIds, 2)"
/>
</div>
</div>
</div>
<div class="field">
<label for="pokemon-things">{{ t('pages.pokemon.favoriteThings') }}</label>
<TagsSelect
id="pokemon-things"
v-model="pokemonForm.favoriteThingIds"
:options="options.favoriteThings"
:max="6"
allow-create
:creating="creatingSelect === 'pokemon-things'"
:placeholder="t('pages.pokemon.searchFavoriteThings')"
@create="createMultiOption('pokemon-things', 'favorite-things', $event, pokemonForm.favoriteThingIds, 6)"
/>
</div>
<div v-if="selectedSkillDropRows.length" class="field">
<span class="field-label">{{ t('pages.pokemon.skillDrops') }}</span>
<div class="skill-drop-list">
<div v-for="row in selectedSkillDropRows" :key="row.skillId" class="skill-drop-row">
<label :for="`pokemon-skill-drops-${row.skillId}`">{{ skillDropLabel(row.skillId) }}</label>
<TagsSelect
:id="`pokemon-skill-drops-${row.skillId}`"
v-model="row.itemId"
:options="itemOptions"
:multiple="false"
:placeholder="t('pages.pokemon.dropItem')"
:search-placeholder="t('pages.pokemon.searchItems')"
/>
</div>
</div>
</div>
</section>
<section v-else class="pokemon-edit-panel" role="tabpanel" :aria-label="t('pages.pokemon.editTabAdvance')">
<TranslationFields
id-prefix="pokemon-genus"
v-model:base-value="pokemonForm.genus"
v-model:translations="pokemonForm.translations"
field="genus"
:label="t('pages.pokemon.genus')"
:languages="languages"
/>
<TranslationFields
id-prefix="pokemon-details"
v-model:base-value="pokemonForm.details"
v-model:translations="pokemonForm.translations"
field="details"
:label="t('pages.pokemon.details')"
:languages="languages"
multiline
:rows="5"
/>
<div class="field">
<span id="pokemon-measurements-label" class="field-label">{{ t('pages.pokemon.measurements') }}</span>
<div class="pokemon-measurement-row" aria-labelledby="pokemon-measurements-label">
<div class="pokemon-measurement-control">
<span id="pokemon-height-label" class="field-label">{{ t('pages.pokemon.height') }}</span>
<div class="segmented" aria-labelledby="pokemon-height-label">
<button :class="{ active: heightUnit === 'imperial' }" type="button" @click="heightUnit = 'imperial'">
{{ t('pages.pokemon.heightImperial') }}
</button>
<button :class="{ active: heightUnit === 'metric' }" type="button" @click="heightUnit = 'metric'">
{{ t('pages.pokemon.heightMetric') }}
</button>
</div>
<div v-if="heightUnit === 'imperial'" class="pokemon-measurement-fields">
<div class="field">
<label for="pokemon-height-feet">{{ t('pages.pokemon.feet') }}</label>
<input id="pokemon-height-feet" :value="heightFeetValue" min="0" step="1" type="number" inputmode="numeric" @input="updateHeightFeet" />
</div>
<div class="field">
<label for="pokemon-height-inches">{{ t('pages.pokemon.inches') }}</label>
<input id="pokemon-height-inches" :value="heightInchesValue" min="0" step="1" type="number" inputmode="numeric" @input="updateHeightInches" />
</div>
</div>
<div v-else class="field">
<label for="pokemon-height-meters">{{ t('pages.pokemon.meters') }}</label>
<input id="pokemon-height-meters" :value="heightMetersValue" min="0" step="0.01" type="number" inputmode="decimal" @input="updateHeightMeters" />
</div>
</div>
<div class="pokemon-measurement-control">
<span id="pokemon-weight-label" class="field-label">{{ t('pages.pokemon.weight') }}</span>
<div class="segmented" aria-labelledby="pokemon-weight-label">
<button :class="{ active: weightUnit === 'imperial' }" type="button" @click="weightUnit = 'imperial'">
{{ t('pages.pokemon.pounds') }}
</button>
<button :class="{ active: weightUnit === 'metric' }" type="button" @click="weightUnit = 'metric'">
{{ t('pages.pokemon.kilograms') }}
</button>
</div>
<div v-if="weightUnit === 'imperial'" class="field">
<label for="pokemon-weight-pounds">{{ t('pages.pokemon.pounds') }}</label>
<input id="pokemon-weight-pounds" :value="weightPoundsValue" min="0" step="0.1" type="number" inputmode="decimal" @input="updateWeightPounds" />
</div>
<div v-else class="field">
<label for="pokemon-weight-kg">{{ t('pages.pokemon.kilograms') }}</label>
<input id="pokemon-weight-kg" :value="weightKgValue" min="0" step="0.01" type="number" inputmode="decimal" @input="updateWeightKg" />
</div>
</div>
</div>
</div>
<div class="field">
<label for="pokemon-types">{{ t('pages.pokemon.types') }}</label>
<TagsSelect
id="pokemon-types"
v-model="pokemonForm.typeIds"
:options="options.pokemonTypes"
:max="2"
allow-create
:creating="creatingSelect === 'pokemon-types'"
:placeholder="t('pages.pokemon.searchTypes')"
@create="createMultiOption('pokemon-types', 'pokemon-types', $event, pokemonForm.typeIds, 2)"
/>
</div>
<div class="field">
<span class="field-label">{{ t('pages.pokemon.statsTitle') }}</span>
<PokemonStatsFields id-prefix="pokemon-stats" v-model="pokemonForm.stats" />
</div>
</section>
</form>
<section v-else class="modal-edit-form skeleton-detail-section" aria-busy="true" :aria-label="t('pages.pokemon.loadingEdit')">