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

@@ -199,6 +199,20 @@ Pokemon 可配置:
- 翻译 - 翻译
- 排序 - 排序
Pokemon 编辑表单使用标签页组织字段:
- 基础标签页:
- 第一行ID、名称
- 第二行:喜欢的环境、特长
- 第三行:喜欢的东西
- 特长掉落物品随已选择且支持掉落物的特长显示
- Advance 标签页:
- 第一行Genus
- 第二行Details
- 第三行Height / Weight身高与体重控件在桌面端同一行展示
- 第四行Types
- 第五行:六维 Stats
Pokemon 列表功能: Pokemon 列表功能:
- 搜索 - 搜索

View File

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

View File

@@ -725,6 +725,69 @@ button:disabled,
gap: 12px; 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-measurement-fields,
.pokemon-stats-fields { .pokemon-stats-fields {
display: grid; display: grid;
@@ -3409,6 +3472,10 @@ button:disabled,
.appearance-row__main { .appearance-row__main {
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
} }
.pokemon-measurement-row {
grid-template-columns: 1fr;
}
} }
@media (max-width: 640px) { @media (max-width: 640px) {
@@ -3436,6 +3503,7 @@ button:disabled,
.toolbar, .toolbar,
.entity-grid, .entity-grid,
.grid, .grid,
.pokemon-edit-grid,
.coming-soon-preview { .coming-soon-preview {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }

View File

@@ -1,12 +1,13 @@
<script setup lang="ts"> <script setup lang="ts">
import { Icon } from '@iconify/vue'; 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 { useI18n } from 'vue-i18n';
import { useRoute, useRouter } from 'vue-router'; import { useRoute, useRouter } from 'vue-router';
import Modal from '../components/Modal.vue'; import Modal from '../components/Modal.vue';
import PokemonStatsFields from '../components/PokemonStatsFields.vue'; import PokemonStatsFields from '../components/PokemonStatsFields.vue';
import Skeleton from '../components/Skeleton.vue'; import Skeleton from '../components/Skeleton.vue';
import StatusMessage from '../components/StatusMessage.vue'; import StatusMessage from '../components/StatusMessage.vue';
import Tabs from '../components/Tabs.vue';
import TagsSelect from '../components/TagsSelect.vue'; import TagsSelect from '../components/TagsSelect.vue';
import TranslationFields from '../components/TranslationFields.vue'; import TranslationFields from '../components/TranslationFields.vue';
import { iconCancel, iconSave } from '../icons'; import { iconCancel, iconSave } from '../icons';
@@ -36,6 +37,7 @@ const loading = ref(true);
const busy = ref(false); const busy = ref(false);
const message = ref(''); const message = ref('');
const creatingSelect = ref(''); const creatingSelect = ref('');
const activeEditTab = ref('basic');
const heightUnit = ref<'imperial' | 'metric'>('imperial'); const heightUnit = ref<'imperial' | 'metric'>('imperial');
const weightUnit = 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(() => const selectedSkillDropRows = computed(() =>
pokemonForm.value.skillItemDrops.filter((row) => pokemonForm.value.skillIds.includes(row.skillId) && skillSupportsItemDrop(row.skillId)) 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 totalHeightInchesValue = computed(() => Math.round(pokemonForm.value.heightInches));
const heightFeetValue = computed(() => Math.floor(totalHeightInchesValue.value / 12)); const heightFeetValue = computed(() => Math.floor(totalHeightInchesValue.value / 12));
const heightInchesValue = computed(() => totalHeightInchesValue.value - heightFeetValue.value * 12); const heightInchesValue = computed(() => totalHeightInchesValue.value - heightFeetValue.value * 12);
@@ -164,6 +170,21 @@ function pokemonNameForSave() {
return pokemonForm.value.translations[String(locale.value || '')]?.name ?? ''; 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() { function closeEditor() {
void router.push(cancelTo.value); void router.push(cancelTo.value);
} }
@@ -241,12 +262,17 @@ async function createMultiOption(selectKey: string, type: ConfigType, name: stri
} }
async function savePokemon() { async function savePokemon() {
if (!hasRequiredBasicFields()) {
await showBasicFieldValidation();
return;
}
busy.value = true; busy.value = true;
message.value = ''; message.value = '';
try { try {
const payload: PokemonPayload = { const payload: PokemonPayload = {
id: Number(isEditing.value ? routeId.value : pokemonForm.value.id), id: pokemonIdForSave(),
name: pokemonNameForSave(), name: pokemonNameForSave(),
genus: pokemonForm.value.genus, genus: pokemonForm.value.genus,
details: pokemonForm.value.details, details: pokemonForm.value.details,
@@ -282,7 +308,11 @@ watch(() => pokemonForm.value.skillIds.slice(), syncSkillItemDrops);
<Modal :title="pageTitle" :subtitle="t('pages.pokemon.editSubtitle')" :close-label="t('common.close')" size="wide" @close="closeEditor"> <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> <StatusMessage v-if="message" variant="danger">{{ message }}</StatusMessage>
<form v-if="!loading && options" id="pokemon-edit-form" class="modal-edit-form" @submit.prevent="savePokemon"> <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')" />
<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"> <div class="field">
<label for="pokemon-id">ID</label> <label for="pokemon-id">ID</label>
<input id="pokemon-id" v-model="pokemonForm.id" :disabled="isEditing" min="1" required type="number" /> <input id="pokemon-id" v-model="pokemonForm.id" :disabled="isEditing" min="1" required type="number" />
@@ -297,97 +327,9 @@ watch(() => pokemonForm.value.skillIds.slice(), syncSkillItemDrops);
:languages="languages" :languages="languages"
required 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">
<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="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>
<div class="pokemon-edit-grid">
<div class="field"> <div class="field">
<label for="pokemon-environment">{{ t('pages.pokemon.environment') }}</label> <label for="pokemon-environment">{{ t('pages.pokemon.environment') }}</label>
<TagsSelect <TagsSelect
@@ -416,6 +358,7 @@ watch(() => pokemonForm.value.skillIds.slice(), syncSkillItemDrops);
@create="createMultiOption('pokemon-skills', 'skills', $event, pokemonForm.skillIds, 2)" @create="createMultiOption('pokemon-skills', 'skills', $event, pokemonForm.skillIds, 2)"
/> />
</div> </div>
</div>
<div class="field"> <div class="field">
<label for="pokemon-things">{{ t('pages.pokemon.favoriteThings') }}</label> <label for="pokemon-things">{{ t('pages.pokemon.favoriteThings') }}</label>
@@ -447,6 +390,104 @@ watch(() => pokemonForm.value.skillIds.slice(), syncSkillItemDrops);
</div> </div>
</div> </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> </form>
<section v-else class="modal-edit-form skeleton-detail-section" aria-busy="true" :aria-label="t('pages.pokemon.loadingEdit')"> <section v-else class="modal-edit-form skeleton-detail-section" aria-busy="true" :aria-label="t('pages.pokemon.loadingEdit')">