feat(ui): add SwitchGroup component and revamp habitat appearance form
Introduce SwitchGroup component for multiple choice toggles Replace TagsSelect with SwitchGroup for time and weather in HabitatEdit Restructure appearance row layout for better responsiveness
This commit is contained in:
60
frontend/src/components/SwitchGroup.vue
Normal file
60
frontend/src/components/SwitchGroup.vue
Normal file
@@ -0,0 +1,60 @@
|
||||
<script setup lang="ts">
|
||||
export type SwitchGroupOption = {
|
||||
value: string;
|
||||
label: string;
|
||||
};
|
||||
|
||||
const props = defineProps<{
|
||||
id: string;
|
||||
label: string;
|
||||
modelValue: string[];
|
||||
options: SwitchGroupOption[];
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
'update:modelValue': [value: string[]];
|
||||
}>();
|
||||
|
||||
function optionId(index: number) {
|
||||
return `${props.id}-${index}`;
|
||||
}
|
||||
|
||||
function isSelected(value: string) {
|
||||
return props.modelValue.includes(value);
|
||||
}
|
||||
|
||||
function updateOption(value: string, event: Event) {
|
||||
if (!(event.target instanceof HTMLInputElement)) return;
|
||||
|
||||
const { checked } = event.target;
|
||||
|
||||
if (checked) {
|
||||
emit('update:modelValue', isSelected(value) ? props.modelValue : [...props.modelValue, value]);
|
||||
return;
|
||||
}
|
||||
|
||||
emit(
|
||||
'update:modelValue',
|
||||
props.modelValue.filter((item) => item !== value)
|
||||
);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<fieldset class="switch-group">
|
||||
<legend>{{ label }}</legend>
|
||||
<div class="switch-group__options">
|
||||
<label v-for="(option, index) in options" :key="option.value" class="switch-control switch-control--stacked">
|
||||
<span class="switch-control__label">{{ option.label }}</span>
|
||||
<input
|
||||
:id="optionId(index)"
|
||||
type="checkbox"
|
||||
:checked="isSelected(option.value)"
|
||||
:value="option.value"
|
||||
@change="updateOption(option.value, $event)"
|
||||
/>
|
||||
<span class="switch-track" aria-hidden="true"></span>
|
||||
</label>
|
||||
</div>
|
||||
</fieldset>
|
||||
</template>
|
||||
@@ -300,7 +300,7 @@ svg {
|
||||
.plain-button,
|
||||
.row-actions button,
|
||||
.inline-row button,
|
||||
.appearance-row button {
|
||||
.appearance-row__delete {
|
||||
--btn-bg: var(--surface);
|
||||
--btn-fg: var(--ink);
|
||||
--btn-border: var(--line-strong);
|
||||
@@ -333,7 +333,7 @@ svg {
|
||||
.plain-button:hover,
|
||||
.row-actions button:hover,
|
||||
.inline-row button:hover,
|
||||
.appearance-row button:hover {
|
||||
.appearance-row__delete:hover {
|
||||
transform: translateY(-2px);
|
||||
box-shadow: 0 5px 0 var(--line-strong);
|
||||
}
|
||||
@@ -344,7 +344,7 @@ svg {
|
||||
.plain-button:active,
|
||||
.row-actions button:active,
|
||||
.inline-row button:active,
|
||||
.appearance-row button:active {
|
||||
.appearance-row__delete:active {
|
||||
transform: translateY(2px);
|
||||
box-shadow: 0 1px 0 var(--line-strong);
|
||||
}
|
||||
@@ -370,7 +370,7 @@ svg {
|
||||
.plain-button,
|
||||
.row-actions button,
|
||||
.inline-row button,
|
||||
.appearance-row button {
|
||||
.appearance-row__delete {
|
||||
--btn-bg: var(--surface);
|
||||
--btn-border: var(--line);
|
||||
box-shadow: none;
|
||||
@@ -1165,7 +1165,7 @@ button:disabled,
|
||||
|
||||
.row-actions button,
|
||||
.inline-row button,
|
||||
.appearance-row button {
|
||||
.appearance-row__delete {
|
||||
min-height: 34px;
|
||||
padding: 6px 10px;
|
||||
font-size: 14px;
|
||||
@@ -1225,6 +1225,7 @@ button:disabled,
|
||||
.appearance-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr;
|
||||
gap: 12px;
|
||||
padding: 12px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: var(--radius-card);
|
||||
@@ -1235,6 +1236,134 @@ button:disabled,
|
||||
min-width: 64px;
|
||||
}
|
||||
|
||||
.appearance-row__main {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(260px, 1.2fr) minmax(240px, 1fr) minmax(180px, 0.9fr) 82px max-content;
|
||||
gap: 12px;
|
||||
align-items: start;
|
||||
}
|
||||
|
||||
.appearance-row__pokemon,
|
||||
.appearance-row__maps,
|
||||
.appearance-row__rarity,
|
||||
.appearance-row__main .switch-group {
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.appearance-row__rarity input {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.appearance-row__delete {
|
||||
align-self: end;
|
||||
justify-self: end;
|
||||
min-height: 32px;
|
||||
padding: 5px 9px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.appearance-row .tags-select,
|
||||
.appearance-row .tags-select__trigger {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.switch-group {
|
||||
min-width: 0;
|
||||
min-inline-size: 0;
|
||||
display: grid;
|
||||
gap: 7px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
}
|
||||
|
||||
.switch-group legend {
|
||||
padding: 0;
|
||||
color: var(--ink-soft);
|
||||
font-size: 14px;
|
||||
font-weight: 850;
|
||||
}
|
||||
|
||||
.switch-group__options {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px 12px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.switch-control {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
min-height: 44px;
|
||||
color: var(--ink-soft);
|
||||
font-weight: 850;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.switch-control--stacked {
|
||||
min-width: 62px;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.switch-control__label {
|
||||
color: var(--ink-soft);
|
||||
font-size: 13px;
|
||||
line-height: 1.2;
|
||||
text-align: center;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
|
||||
.switch-control input {
|
||||
position: absolute;
|
||||
inline-size: 1px;
|
||||
block-size: 1px;
|
||||
min-width: 0;
|
||||
margin: 0;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.switch-track {
|
||||
position: relative;
|
||||
width: 48px;
|
||||
height: 28px;
|
||||
flex: 0 0 auto;
|
||||
border: 2px solid var(--line-strong);
|
||||
border-radius: 999px;
|
||||
background: var(--line);
|
||||
transition: background 0.16s ease;
|
||||
}
|
||||
|
||||
.switch-track::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
background: var(--surface);
|
||||
box-shadow: 0 2px 0 rgba(0, 0, 0, 0.2);
|
||||
transition: transform 0.16s ease;
|
||||
}
|
||||
|
||||
.switch-control input:focus-visible + .switch-track {
|
||||
box-shadow: 0 0 0 4px rgba(42, 117, 187, 0.16);
|
||||
}
|
||||
|
||||
.switch-control input:checked + .switch-track {
|
||||
background: var(--pokemon-blue);
|
||||
}
|
||||
|
||||
.switch-control input:checked + .switch-track::after {
|
||||
transform: translateX(20px);
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.top-nav {
|
||||
grid-template-columns: 1fr;
|
||||
@@ -1271,6 +1400,10 @@ button:disabled,
|
||||
.admin-layout {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.appearance-row__main {
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
@@ -1322,4 +1455,8 @@ button:disabled,
|
||||
.inline-row .tags-select {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.appearance-row__main {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ 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 {
|
||||
api,
|
||||
@@ -40,8 +41,8 @@ const habitatForm = ref({
|
||||
|
||||
const timeOfDays = ['早晨', '中午', '傍晚', '晚上'];
|
||||
const weathers = ['晴天', '阴天', '雨天'];
|
||||
const timeOfDayOptions = timeOfDays.map((name) => ({ id: name, name }));
|
||||
const weatherOptions = weathers.map((name) => ({ id: name, name }));
|
||||
const timeOfDayOptions = timeOfDays.map((value) => ({ value, label: value }));
|
||||
const weatherOptions = weathers.map((value) => ({ value, label: value }));
|
||||
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 })));
|
||||
@@ -220,6 +221,9 @@ onMounted(() => {
|
||||
<div class="field">
|
||||
<label>可出现的 Pokemon</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"
|
||||
@@ -228,6 +232,20 @@ onMounted(() => {
|
||||
placeholder="Pokemon"
|
||||
search-placeholder="搜索 Pokemon"
|
||||
/>
|
||||
</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" />
|
||||
|
||||
<div class="field appearance-row__rarity">
|
||||
<label :for="`appearance-rarity-${index}`">稀有度</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>
|
||||
</div>
|
||||
|
||||
<div class="field appearance-row__maps">
|
||||
<label :for="`appearance-maps-${index}`">地图</label>
|
||||
<TagsSelect
|
||||
:id="`appearance-maps-${index}`"
|
||||
v-model="row.mapIds"
|
||||
@@ -237,10 +255,7 @@ onMounted(() => {
|
||||
placeholder="搜索地图"
|
||||
@create="createMultiOption(`appearance-maps-${index}`, 'maps', $event, row.mapIds)"
|
||||
/>
|
||||
<TagsSelect :id="`appearance-times-${index}`" v-model="row.timeOfDays" :options="timeOfDayOptions" placeholder="搜索时间" />
|
||||
<TagsSelect :id="`appearance-weathers-${index}`" v-model="row.weathers" :options="weatherOptions" placeholder="搜索天气" />
|
||||
<input v-model.number="row.rarity" aria-label="稀有度" type="number" min="1" max="3" />
|
||||
<button type="button" @click="habitatForm.pokemonAppearances.splice(index, 1)">删除</button>
|
||||
</div>
|
||||
</div>
|
||||
<button type="button" class="plain-button" @click="addPokemonAppearance">添加 Pokemon</button>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user