feat(ui): replace text loading states with skeleton loaders

Add skeleton CSS classes to main.css for consistent loading UI
Apply skeleton loading states to Admin, Habitat, Item, Pokemon, Recipe, and Auth views
Improve perceived performance and accessibility during data fetching
This commit is contained in:
2026-04-30 14:20:57 +08:00
parent ba5aae7136
commit dabbf0ec70
9 changed files with 341 additions and 32 deletions

View File

@@ -728,6 +728,50 @@ button:disabled,
height: 28px; height: 28px;
} }
.page-header--skeleton {
pointer-events: none;
}
.skeleton-detail-section {
pointer-events: none;
}
.skeleton-detail-section .detail-section__body {
gap: 14px;
}
.skeleton-row-list li {
min-height: 43px;
}
.skeleton-appearance-row {
display: grid;
grid-template-columns: max-content minmax(0, 1fr);
gap: 12px;
}
.skeleton-summary {
display: grid;
gap: 6px;
width: 100%;
}
.skeleton-summary div {
display: grid;
grid-template-columns: 72px minmax(0, 1fr);
gap: 8px;
}
.skeleton-form-stack {
display: grid;
gap: 14px;
}
.skeleton-auth-state {
display: grid;
gap: 12px;
}
@keyframes shimmer { @keyframes shimmer {
to { to {
background-position: -200% 0; background-position: -200% 0;

View File

@@ -1,6 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, ref } from 'vue'; import { computed, onMounted, ref } from 'vue';
import PageHeader from '../components/PageHeader.vue'; import PageHeader from '../components/PageHeader.vue';
import Skeleton from '../components/Skeleton.vue';
import StatusMessage from '../components/StatusMessage.vue'; import StatusMessage from '../components/StatusMessage.vue';
import TagsSelect from '../components/TagsSelect.vue'; import TagsSelect from '../components/TagsSelect.vue';
import { import {
@@ -64,6 +65,7 @@ const recipeRows = ref<Recipe[]>([]);
const habitatRows = ref<Habitat[]>([]); const habitatRows = ref<Habitat[]>([]);
const currentUser = ref<AuthUser | null>(null); const currentUser = ref<AuthUser | null>(null);
const busy = ref(false); const busy = ref(false);
const contentLoading = ref(false);
const message = ref(''); const message = ref('');
const creatingSelect = ref(''); const creatingSelect = ref('');
@@ -99,6 +101,7 @@ const pokemonSelectOptions = computed(() =>
pokemonRows.value.map((pokemon) => ({ id: pokemon.id, name: pokemon.name, label: `#${pokemon.id} ${pokemon.name}` })) pokemonRows.value.map((pokemon) => ({ id: pokemon.id, name: pokemon.name, label: `#${pokemon.id} ${pokemon.name}` }))
); );
const canEdit = computed(() => currentUser.value?.emailVerified === true); const canEdit = computed(() => currentUser.value?.emailVerified === true);
const showAdminSkeleton = computed(() => busy.value && !message.value && (!currentUser.value || contentLoading.value));
function toIds(values: string[]): number[] { function toIds(values: string[]): number[] {
return values.map(Number).filter((item) => Number.isInteger(item) && item > 0); return values.map(Number).filter((item) => Number.isInteger(item) && item > 0);
@@ -168,7 +171,13 @@ async function loadHabitats() {
habitatRows.value = await api.habitats(); habitatRows.value = await api.habitats();
} }
async function loadCurrentTab() { async function loadCurrentTab(showSkeleton = false) {
const shouldShowSkeleton = showSkeleton || !options.value;
if (shouldShowSkeleton) {
contentLoading.value = true;
}
try {
await loadOptions(); await loadOptions();
if (activeTab.value === 'config') await loadConfig(); if (activeTab.value === 'config') await loadConfig();
if (activeTab.value === 'pokemon') await loadPokemon(); if (activeTab.value === 'pokemon') await loadPokemon();
@@ -181,6 +190,11 @@ async function loadCurrentTab() {
if (activeTab.value === 'habitats') { if (activeTab.value === 'habitats') {
await Promise.all([loadHabitats(), loadPokemon(), loadItems()]); await Promise.all([loadHabitats(), loadPokemon(), loadItems()]);
} }
} finally {
if (shouldShowSkeleton) {
contentLoading.value = false;
}
}
} }
function setTab(tab: AdminTab) { function setTab(tab: AdminTab) {
@@ -190,7 +204,7 @@ function setTab(tab: AdminTab) {
} }
activeTab.value = tab; activeTab.value = tab;
void run(loadCurrentTab); void run(() => loadCurrentTab(true));
} }
async function loadAdmin() { async function loadAdmin() {
@@ -202,7 +216,7 @@ async function loadAdmin() {
return; return;
} }
await loadCurrentTab(); await loadCurrentTab(true);
} }
function resetConfigForm() { function resetConfigForm() {
@@ -482,7 +496,36 @@ onMounted(() => {
<StatusMessage v-if="message" variant="warning">{{ message }}</StatusMessage> <StatusMessage v-if="message" variant="warning">{{ message }}</StatusMessage>
<section v-if="canEdit && activeTab === 'config'" class="admin-layout"> <section v-if="showAdminSkeleton" class="admin-layout" aria-busy="true" aria-label="正在加载管理内容">
<div class="detail-section skeleton-detail-section" aria-hidden="true">
<h2><Skeleton width="96px" height="24px" /></h2>
<div class="skeleton-form-stack">
<div v-for="index in 5" :key="index" class="field">
<Skeleton :width="index === 1 ? '52px' : '72px'" />
<Skeleton variant="box" height="44px" />
</div>
<div class="form-actions">
<Skeleton variant="box" width="64px" height="42px" />
<Skeleton variant="box" width="64px" height="42px" />
</div>
</div>
</div>
<div class="detail-section skeleton-detail-section" aria-hidden="true">
<h2><Skeleton width="120px" height="24px" /></h2>
<ul class="row-list skeleton-row-list">
<li v-for="index in 6" :key="index">
<Skeleton :width="index % 2 === 0 ? '180px' : '132px'" />
<span class="row-actions">
<Skeleton variant="box" width="50px" height="34px" />
<Skeleton variant="box" width="50px" height="34px" />
</span>
</li>
</ul>
</div>
</section>
<section v-else-if="canEdit && activeTab === 'config'" class="admin-layout">
<form class="detail-section" @submit.prevent="saveConfig"> <form class="detail-section" @submit.prevent="saveConfig">
<h2>系统配置</h2> <h2>系统配置</h2>
<div class="field"> <div class="field">
@@ -519,7 +562,7 @@ onMounted(() => {
</div> </div>
</section> </section>
<section v-if="canEdit && activeTab === 'pokemon' && options" class="admin-layout"> <section v-else-if="canEdit && activeTab === 'pokemon' && options" class="admin-layout">
<form class="detail-section" @submit.prevent="savePokemon"> <form class="detail-section" @submit.prevent="savePokemon">
<h2>Pokemon</h2> <h2>Pokemon</h2>
<div class="field"><label for="pokemon-id">ID</label><input id="pokemon-id" v-model="pokemonForm.id" type="number" /></div> <div class="field"><label for="pokemon-id">ID</label><input id="pokemon-id" v-model="pokemonForm.id" type="number" /></div>
@@ -581,7 +624,7 @@ onMounted(() => {
</div> </div>
</section> </section>
<section v-if="canEdit && activeTab === 'items' && options" class="admin-layout"> <section v-else-if="canEdit && activeTab === 'items' && options" class="admin-layout">
<form class="detail-section" @submit.prevent="saveItem"> <form class="detail-section" @submit.prevent="saveItem">
<h2>物品</h2> <h2>物品</h2>
<div class="field"><label for="item-name">名称</label><input id="item-name" v-model="itemForm.name" /></div> <div class="field"><label for="item-name">名称</label><input id="item-name" v-model="itemForm.name" /></div>
@@ -656,7 +699,7 @@ onMounted(() => {
</div> </div>
</section> </section>
<section v-if="canEdit && activeTab === 'recipes' && options" class="admin-layout"> <section v-else-if="canEdit && activeTab === 'recipes' && options" class="admin-layout">
<form class="detail-section" @submit.prevent="saveRecipe"> <form class="detail-section" @submit.prevent="saveRecipe">
<h2>材料单</h2> <h2>材料单</h2>
<div class="field"> <div class="field">
@@ -718,7 +761,7 @@ onMounted(() => {
</div> </div>
</section> </section>
<section v-if="canEdit && activeTab === 'habitats' && options" class="admin-layout"> <section v-else-if="canEdit && activeTab === 'habitats' && options" class="admin-layout">
<form class="detail-section" @submit.prevent="saveHabitat"> <form class="detail-section" @submit.prevent="saveHabitat">
<h2>栖息地</h2> <h2>栖息地</h2>
<div class="field"><label for="habitat-name">名称</label><input id="habitat-name" v-model="habitatForm.name" /></div> <div class="field"><label for="habitat-name">名称</label><input id="habitat-name" v-model="habitatForm.name" /></div>

View File

@@ -5,7 +5,7 @@ import DetailSection from '../components/DetailSection.vue';
import EditMeta from '../components/EditMeta.vue'; import EditMeta from '../components/EditMeta.vue';
import EntityChips from '../components/EntityChips.vue'; import EntityChips from '../components/EntityChips.vue';
import PageHeader from '../components/PageHeader.vue'; import PageHeader from '../components/PageHeader.vue';
import StatusMessage from '../components/StatusMessage.vue'; import Skeleton from '../components/Skeleton.vue';
import { api, type HabitatDetail } from '../services/api'; import { api, type HabitatDetail } from '../services/api';
const route = useRoute(); const route = useRoute();
@@ -81,7 +81,51 @@ onMounted(async () => {
</script> </script>
<template> <template>
<StatusMessage v-if="!habitat" :duration="0">加载中</StatusMessage> <section v-if="!habitat" class="page-stack" aria-busy="true" aria-label="正在加载栖息地详情">
<div class="page-header page-header--skeleton" aria-hidden="true">
<div class="page-header__copy">
<Skeleton width="132px" />
<Skeleton width="240px" height="46px" />
<Skeleton width="120px" />
<Skeleton width="300px" />
</div>
<div class="page-header__actions">
<Skeleton variant="box" width="88px" height="36px" />
</div>
</div>
<div class="detail-grid" aria-hidden="true">
<section class="detail-section skeleton-detail-section">
<div class="detail-section__header">
<Skeleton width="92px" height="24px" />
</div>
<div class="detail-section__body">
<div class="skeleton-chip-row">
<Skeleton v-for="index in 4" :key="index" width="76px" class="skeleton-chip" />
</div>
</div>
</section>
<section class="detail-section skeleton-detail-section">
<div class="detail-section__header">
<Skeleton width="148px" height="24px" />
</div>
<div class="detail-section__body">
<ul class="row-list appearance-list skeleton-row-list">
<li v-for="index in 3" :key="index" class="skeleton-appearance-row">
<Skeleton width="104px" />
<div class="skeleton-summary">
<div v-for="line in 4" :key="line">
<Skeleton width="56px" />
<Skeleton :width="line === 4 ? '72%' : '46%'" />
</div>
</div>
</li>
</ul>
</div>
</section>
</div>
</section>
<section v-else class="page-stack"> <section v-else class="page-stack">
<PageHeader :title="habitat.name" subtitle="栖息地详情"> <PageHeader :title="habitat.name" subtitle="栖息地详情">
<template #kicker>Habitat Detail</template> <template #kicker>Habitat Detail</template>

View File

@@ -4,11 +4,12 @@ import EditMeta from '../components/EditMeta.vue';
import EntityChips from '../components/EntityChips.vue'; import EntityChips from '../components/EntityChips.vue';
import EntityCard from '../components/EntityCard.vue'; import EntityCard from '../components/EntityCard.vue';
import PageHeader from '../components/PageHeader.vue'; import PageHeader from '../components/PageHeader.vue';
import StatusMessage from '../components/StatusMessage.vue'; import Skeleton from '../components/Skeleton.vue';
import { api, type Habitat } from '../services/api'; import { api, type Habitat } from '../services/api';
const habitats = ref<Habitat[]>([]); const habitats = ref<Habitat[]>([]);
const loading = ref(true); const loading = ref(true);
const skeletonCardCount = 6;
onMounted(async () => { onMounted(async () => {
habitats.value = await api.habitats(); habitats.value = await api.habitats();
@@ -22,7 +23,21 @@ onMounted(async () => {
<template #kicker>Habitats</template> <template #kicker>Habitats</template>
</PageHeader> </PageHeader>
<StatusMessage v-if="loading" :duration="0">加载中</StatusMessage> <div v-if="loading" class="entity-grid" aria-busy="true" aria-label="正在加载栖息地列表">
<article v-for="index in skeletonCardCount" :key="index" class="entity-card entity-card--skeleton">
<Skeleton variant="box" width="42px" height="42px" class="skeleton-entity-mark" />
<div class="entity-card__content">
<Skeleton width="68%" height="24px" />
<Skeleton width="66%" />
<div class="skeleton-chip-row">
<Skeleton v-for="chipIndex in 3" :key="`recipe-${chipIndex}`" width="70px" class="skeleton-chip" />
</div>
<div class="skeleton-chip-row">
<Skeleton v-for="chipIndex in 2" :key="`pokemon-${chipIndex}`" width="82px" class="skeleton-chip" />
</div>
</div>
</article>
</div>
<div v-else class="entity-grid"> <div v-else class="entity-grid">
<EntityCard v-for="item in habitats" :key="item.id" :title="item.name" :to="`/habitats/${item.id}`" marker="◎"> <EntityCard v-for="item in habitats" :key="item.id" :title="item.name" :to="`/habitats/${item.id}`" marker="◎">
<EditMeta :entity="item" /> <EditMeta :entity="item" />

View File

@@ -5,7 +5,7 @@ import DetailSection from '../components/DetailSection.vue';
import EditMeta from '../components/EditMeta.vue'; import EditMeta from '../components/EditMeta.vue';
import EntityChips from '../components/EntityChips.vue'; import EntityChips from '../components/EntityChips.vue';
import PageHeader from '../components/PageHeader.vue'; import PageHeader from '../components/PageHeader.vue';
import StatusMessage from '../components/StatusMessage.vue'; import Skeleton from '../components/Skeleton.vue';
import { api, type ItemDetail } from '../services/api'; import { api, type ItemDetail } from '../services/api';
const route = useRoute(); const route = useRoute();
@@ -29,7 +29,58 @@ onMounted(async () => {
</script> </script>
<template> <template>
<StatusMessage v-if="!item" :duration="0">加载中</StatusMessage> <section v-if="!item" class="page-stack" aria-busy="true" aria-label="正在加载物品详情">
<div class="page-header page-header--skeleton" aria-hidden="true">
<div class="page-header__copy">
<Skeleton width="96px" />
<Skeleton width="260px" height="46px" />
<Skeleton width="220px" />
<Skeleton width="300px" />
</div>
<div class="page-header__actions">
<Skeleton variant="box" width="88px" height="36px" />
</div>
</div>
<div class="detail-grid" aria-hidden="true">
<section v-for="index in 3" :key="`chips-${index}`" class="detail-section skeleton-detail-section">
<div class="detail-section__header">
<Skeleton :width="index === 2 ? '68px' : '92px'" height="24px" />
</div>
<div class="detail-section__body">
<div class="skeleton-chip-row">
<Skeleton v-for="chipIndex in 3" :key="chipIndex" width="82px" class="skeleton-chip" />
</div>
</div>
</section>
<section class="detail-section skeleton-detail-section">
<div class="detail-section__header">
<Skeleton width="112px" height="24px" />
</div>
<div class="detail-section__body">
<Skeleton width="45%" />
<div class="skeleton-chip-row">
<Skeleton v-for="index in 3" :key="index" width="76px" class="skeleton-chip" />
</div>
</div>
</section>
<section class="detail-section skeleton-detail-section">
<div class="detail-section__header">
<Skeleton width="108px" height="24px" />
</div>
<div class="detail-section__body">
<ul class="row-list skeleton-row-list">
<li v-for="index in 3" :key="index">
<Skeleton width="120px" />
<Skeleton width="44px" />
</li>
</ul>
</div>
</section>
</div>
</section>
<section v-else class="page-stack"> <section v-else class="page-stack">
<PageHeader :title="item.name" :subtitle="item.usage ? `${item.category.name} · ${item.usage.name}` : item.category.name"> <PageHeader :title="item.name" :subtitle="item.usage ? `${item.category.name} · ${item.usage.name}` : item.category.name">
<template #kicker>Item Detail</template> <template #kicker>Item Detail</template>

View File

@@ -5,7 +5,7 @@ import DetailSection from '../components/DetailSection.vue';
import EditMeta from '../components/EditMeta.vue'; import EditMeta from '../components/EditMeta.vue';
import EntityChips from '../components/EntityChips.vue'; import EntityChips from '../components/EntityChips.vue';
import PageHeader from '../components/PageHeader.vue'; import PageHeader from '../components/PageHeader.vue';
import StatusMessage from '../components/StatusMessage.vue'; import Skeleton from '../components/Skeleton.vue';
import { api, type PokemonDetail } from '../services/api'; import { api, type PokemonDetail } from '../services/api';
const route = useRoute(); const route = useRoute();
@@ -81,7 +81,62 @@ onMounted(async () => {
</script> </script>
<template> <template>
<StatusMessage v-if="!pokemon" :duration="0">加载中</StatusMessage> <section v-if="!pokemon" class="page-stack" aria-busy="true" aria-label="正在加载 Pokemon 详情">
<div class="page-header page-header--skeleton" aria-hidden="true">
<div class="page-header__copy">
<Skeleton width="142px" />
<Skeleton width="280px" height="46px" />
<Skeleton width="220px" />
<Skeleton width="310px" />
</div>
<div class="page-header__actions">
<Skeleton variant="box" width="88px" height="36px" />
</div>
</div>
<div class="detail-grid" aria-hidden="true">
<section class="detail-section skeleton-detail-section">
<div class="detail-section__header">
<Skeleton width="56px" height="24px" />
</div>
<div class="detail-section__body">
<div class="skeleton-chip-row">
<Skeleton v-for="index in 2" :key="index" width="74px" class="skeleton-chip" />
</div>
</div>
</section>
<section class="detail-section skeleton-detail-section">
<div class="detail-section__header">
<Skeleton width="92px" height="24px" />
</div>
<div class="detail-section__body">
<div class="skeleton-chip-row">
<Skeleton v-for="index in 4" :key="index" width="82px" class="skeleton-chip" />
</div>
</div>
</section>
<section class="detail-section skeleton-detail-section">
<div class="detail-section__header">
<Skeleton width="68px" height="24px" />
</div>
<div class="detail-section__body">
<ul class="row-list appearance-list skeleton-row-list">
<li v-for="index in 3" :key="index" class="skeleton-appearance-row">
<Skeleton width="96px" />
<div class="skeleton-summary">
<div v-for="line in 4" :key="line">
<Skeleton width="56px" />
<Skeleton :width="line === 4 ? '70%' : '48%'" />
</div>
</div>
</li>
</ul>
</div>
</section>
</div>
</section>
<section v-else class="page-stack"> <section v-else class="page-stack">
<PageHeader :title="`#${pokemon.id} ${pokemon.name}`" :subtitle="`喜欢的环境:${pokemon.environment.name}`"> <PageHeader :title="`#${pokemon.id} ${pokemon.name}`" :subtitle="`喜欢的环境:${pokemon.environment.name}`">
<template #kicker>Pokédex Detail</template> <template #kicker>Pokédex Detail</template>

View File

@@ -5,7 +5,7 @@ import EntityChips from '../components/EntityChips.vue';
import EntityCard from '../components/EntityCard.vue'; import EntityCard from '../components/EntityCard.vue';
import FilterPanel from '../components/FilterPanel.vue'; import FilterPanel from '../components/FilterPanel.vue';
import PageHeader from '../components/PageHeader.vue'; import PageHeader from '../components/PageHeader.vue';
import StatusMessage from '../components/StatusMessage.vue'; import Skeleton from '../components/Skeleton.vue';
import TagsSelect from '../components/TagsSelect.vue'; import TagsSelect from '../components/TagsSelect.vue';
import { api, type Options, type Pokemon } from '../services/api'; import { api, type Options, type Pokemon } from '../services/api';
@@ -18,6 +18,8 @@ const skillIds = ref<string[]>([]);
const skillMode = ref<'any' | 'all'>('any'); const skillMode = ref<'any' | 'all'>('any');
const favoriteThingIds = ref<string[]>([]); const favoriteThingIds = ref<string[]>([]);
const favoriteThingMode = ref<'any' | 'all'>('any'); const favoriteThingMode = ref<'any' | 'all'>('any');
const filterSkeletonWidths = ['52px', '92px', '48px', '72px'];
const skeletonCardCount = 6;
const query = computed(() => ({ const query = computed(() => ({
search: search.value, search: search.value,
@@ -88,8 +90,33 @@ watch(query, loadPokemon);
</div> </div>
</div> </div>
</FilterPanel> </FilterPanel>
<FilterPanel v-else class="filter-panel--skeleton" aria-hidden="true">
<div v-for="(width, index) in filterSkeletonWidths" :key="index" class="field">
<Skeleton :width="width" />
<Skeleton variant="box" height="44px" />
<div v-if="index > 1" class="segmented">
<Skeleton variant="box" width="52px" height="34px" />
<Skeleton variant="box" width="52px" height="34px" />
</div>
</div>
</FilterPanel>
<StatusMessage v-if="loading" :duration="0">加载中</StatusMessage> <div v-if="loading" class="entity-grid" aria-busy="true" aria-label="正在加载 Pokemon 列表">
<article v-for="index in skeletonCardCount" :key="index" class="entity-card entity-card--skeleton">
<Skeleton variant="box" width="42px" height="42px" class="skeleton-entity-mark" />
<div class="entity-card__content">
<Skeleton width="76%" height="24px" />
<Skeleton width="58%" />
<Skeleton width="68%" />
<div class="skeleton-chip-row">
<Skeleton v-for="chipIndex in 2" :key="`skills-${chipIndex}`" width="64px" class="skeleton-chip" />
</div>
<div class="skeleton-chip-row">
<Skeleton v-for="chipIndex in 3" :key="`things-${chipIndex}`" width="72px" class="skeleton-chip" />
</div>
</div>
</article>
</div>
<div v-else class="entity-grid"> <div v-else class="entity-grid">
<EntityCard <EntityCard
v-for="item in pokemon" v-for="item in pokemon"

View File

@@ -5,7 +5,7 @@ import DetailSection from '../components/DetailSection.vue';
import EditMeta from '../components/EditMeta.vue'; import EditMeta from '../components/EditMeta.vue';
import EntityChips from '../components/EntityChips.vue'; import EntityChips from '../components/EntityChips.vue';
import PageHeader from '../components/PageHeader.vue'; import PageHeader from '../components/PageHeader.vue';
import StatusMessage from '../components/StatusMessage.vue'; import Skeleton from '../components/Skeleton.vue';
import { api, type RecipeDetail } from '../services/api'; import { api, type RecipeDetail } from '../services/api';
const route = useRoute(); const route = useRoute();
@@ -17,7 +17,32 @@ onMounted(async () => {
</script> </script>
<template> <template>
<StatusMessage v-if="!recipe" :duration="0">加载中</StatusMessage> <section v-if="!recipe" class="page-stack" aria-busy="true" aria-label="正在加载材料单详情">
<div class="page-header page-header--skeleton" aria-hidden="true">
<div class="page-header__copy">
<Skeleton width="112px" />
<Skeleton width="260px" height="46px" />
<Skeleton width="128px" />
<Skeleton width="300px" />
</div>
<div class="page-header__actions">
<Skeleton variant="box" width="88px" height="36px" />
</div>
</div>
<div class="detail-grid" aria-hidden="true">
<section v-for="index in 2" :key="index" class="detail-section skeleton-detail-section">
<div class="detail-section__header">
<Skeleton :width="index === 1 ? '92px' : '88px'" height="24px" />
</div>
<div class="detail-section__body">
<div class="skeleton-chip-row">
<Skeleton v-for="chipIndex in index === 1 ? 3 : 4" :key="chipIndex" width="82px" class="skeleton-chip" />
</div>
</div>
</section>
</div>
</section>
<section v-else class="page-stack"> <section v-else class="page-stack">
<PageHeader :title="recipe.name" subtitle="材料单详情"> <PageHeader :title="recipe.name" subtitle="材料单详情">
<template #kicker>Recipe Detail</template> <template #kicker>Recipe Detail</template>

View File

@@ -2,6 +2,7 @@
import { onMounted, ref } from 'vue'; import { onMounted, ref } from 'vue';
import { useRoute } from 'vue-router'; import { useRoute } from 'vue-router';
import PageHeader from '../components/PageHeader.vue'; import PageHeader from '../components/PageHeader.vue';
import Skeleton from '../components/Skeleton.vue';
import StatusMessage from '../components/StatusMessage.vue'; import StatusMessage from '../components/StatusMessage.vue';
import { api } from '../services/api'; import { api } from '../services/api';
@@ -37,11 +38,15 @@ onMounted(async () => {
<template #kicker>Trainer Pass</template> <template #kicker>Trainer Pass</template>
</PageHeader> </PageHeader>
<StatusMessage v-if="busy" :duration="0">正在验证邮箱</StatusMessage> <div v-if="busy" class="skeleton-auth-state" aria-busy="true" aria-label="正在验证邮箱">
<Skeleton width="62%" />
<Skeleton width="84%" />
<Skeleton variant="box" width="110px" height="44px" />
</div>
<StatusMessage v-else-if="message" variant="success">{{ message }}</StatusMessage> <StatusMessage v-else-if="message" variant="success">{{ message }}</StatusMessage>
<StatusMessage v-else variant="danger">{{ errorMessage }}</StatusMessage> <StatusMessage v-else variant="danger">{{ errorMessage }}</StatusMessage>
<RouterLink class="ui-button ui-button--primary" to="/login">去登录</RouterLink> <RouterLink v-if="!busy" class="ui-button ui-button--primary" to="/login">去登录</RouterLink>
</div> </div>
</section> </section>
</template> </template>