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:
2026-04-30 17:27:26 +08:00
parent 45e0276158
commit 5e2d918b37
3 changed files with 240 additions and 28 deletions

View 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>

View File

@@ -300,7 +300,7 @@ svg {
.plain-button, .plain-button,
.row-actions button, .row-actions button,
.inline-row button, .inline-row button,
.appearance-row button { .appearance-row__delete {
--btn-bg: var(--surface); --btn-bg: var(--surface);
--btn-fg: var(--ink); --btn-fg: var(--ink);
--btn-border: var(--line-strong); --btn-border: var(--line-strong);
@@ -333,7 +333,7 @@ svg {
.plain-button:hover, .plain-button:hover,
.row-actions button:hover, .row-actions button:hover,
.inline-row button:hover, .inline-row button:hover,
.appearance-row button:hover { .appearance-row__delete:hover {
transform: translateY(-2px); transform: translateY(-2px);
box-shadow: 0 5px 0 var(--line-strong); box-shadow: 0 5px 0 var(--line-strong);
} }
@@ -344,7 +344,7 @@ svg {
.plain-button:active, .plain-button:active,
.row-actions button:active, .row-actions button:active,
.inline-row button:active, .inline-row button:active,
.appearance-row button:active { .appearance-row__delete:active {
transform: translateY(2px); transform: translateY(2px);
box-shadow: 0 1px 0 var(--line-strong); box-shadow: 0 1px 0 var(--line-strong);
} }
@@ -370,7 +370,7 @@ svg {
.plain-button, .plain-button,
.row-actions button, .row-actions button,
.inline-row button, .inline-row button,
.appearance-row button { .appearance-row__delete {
--btn-bg: var(--surface); --btn-bg: var(--surface);
--btn-border: var(--line); --btn-border: var(--line);
box-shadow: none; box-shadow: none;
@@ -1165,7 +1165,7 @@ button:disabled,
.row-actions button, .row-actions button,
.inline-row button, .inline-row button,
.appearance-row button { .appearance-row__delete {
min-height: 34px; min-height: 34px;
padding: 6px 10px; padding: 6px 10px;
font-size: 14px; font-size: 14px;
@@ -1225,6 +1225,7 @@ button:disabled,
.appearance-row { .appearance-row {
display: grid; display: grid;
grid-template-columns: 1fr; grid-template-columns: 1fr;
gap: 12px;
padding: 12px; padding: 12px;
border: 1px solid var(--line); border: 1px solid var(--line);
border-radius: var(--radius-card); border-radius: var(--radius-card);
@@ -1235,6 +1236,134 @@ button:disabled,
min-width: 64px; 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) { @media (max-width: 900px) {
.top-nav { .top-nav {
grid-template-columns: 1fr; grid-template-columns: 1fr;
@@ -1271,6 +1400,10 @@ button:disabled,
.admin-layout { .admin-layout {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.appearance-row__main {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
} }
@media (max-width: 640px) { @media (max-width: 640px) {
@@ -1322,4 +1455,8 @@ button:disabled,
.inline-row .tags-select { .inline-row .tags-select {
width: 100%; width: 100%;
} }
.appearance-row__main {
grid-template-columns: 1fr;
}
} }

View File

@@ -4,6 +4,7 @@ import { useRoute, useRouter } from 'vue-router';
import PageHeader from '../components/PageHeader.vue'; import PageHeader from '../components/PageHeader.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 SwitchGroup from '../components/SwitchGroup.vue';
import TagsSelect from '../components/TagsSelect.vue'; import TagsSelect from '../components/TagsSelect.vue';
import { import {
api, api,
@@ -40,8 +41,8 @@ const habitatForm = ref({
const timeOfDays = ['早晨', '中午', '傍晚', '晚上']; const timeOfDays = ['早晨', '中午', '傍晚', '晚上'];
const weathers = ['晴天', '阴天', '雨天']; const weathers = ['晴天', '阴天', '雨天'];
const timeOfDayOptions = timeOfDays.map((name) => ({ id: name, name })); const timeOfDayOptions = timeOfDays.map((value) => ({ value, label: value }));
const weatherOptions = weathers.map((name) => ({ id: name, name })); const weatherOptions = weathers.map((value) => ({ value, label: value }));
const routeId = computed(() => (typeof route.params.id === 'string' ? route.params.id : '')); const routeId = computed(() => (typeof route.params.id === 'string' ? route.params.id : ''));
const isEditing = computed(() => routeId.value !== ''); const isEditing = computed(() => routeId.value !== '');
const itemSelectOptions = computed(() => itemRows.value.map((item) => ({ id: item.id, name: item.name }))); const itemSelectOptions = computed(() => itemRows.value.map((item) => ({ id: item.id, name: item.name })));
@@ -220,6 +221,9 @@ onMounted(() => {
<div class="field"> <div class="field">
<label>可出现的 Pokemon</label> <label>可出现的 Pokemon</label>
<div v-for="(row, index) in habitatForm.pokemonAppearances" :key="index" class="appearance-row"> <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 <TagsSelect
:id="`appearance-pokemon-${index}`" :id="`appearance-pokemon-${index}`"
v-model="row.pokemonId" v-model="row.pokemonId"
@@ -228,6 +232,20 @@ onMounted(() => {
placeholder="Pokemon" placeholder="Pokemon"
search-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 <TagsSelect
:id="`appearance-maps-${index}`" :id="`appearance-maps-${index}`"
v-model="row.mapIds" v-model="row.mapIds"
@@ -237,10 +255,7 @@ onMounted(() => {
placeholder="搜索地图" placeholder="搜索地图"
@create="createMultiOption(`appearance-maps-${index}`, 'maps', $event, row.mapIds)" @create="createMultiOption(`appearance-maps-${index}`, 'maps', $event, row.mapIds)"
/> />
<TagsSelect :id="`appearance-times-${index}`" v-model="row.timeOfDays" :options="timeOfDayOptions" placeholder="搜索时间" /> </div>
<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> <button type="button" class="plain-button" @click="addPokemonAppearance">添加 Pokemon</button>
</div> </div>