添加首页加载skeleton

This commit is contained in:
2026-05-26 17:04:04 +08:00
parent 9680276b3f
commit 2f528c049f
10 changed files with 1329 additions and 410 deletions

View File

@@ -0,0 +1,368 @@
<template>
<view class="home-skeleton-root">
<view class="home-skeleton-header" :style="{ paddingTop: statusBarHeight + 'px' }">
<view class="home-skeleton-tab-row" :style="capsuleStyle">
<view class="home-skeleton-tab home-skeleton-tab-active skeleton-shimmer"></view>
<view class="home-skeleton-tab skeleton-shimmer"></view>
</view>
<view class="home-skeleton-search skeleton-shimmer">
<view class="home-skeleton-search-icon"></view>
<view class="home-skeleton-search-line"></view>
</view>
<scroll-view
v-if="showCategoryBar"
class="home-skeleton-category-scroll"
direction="horizontal"
:scroll-x="true"
:show-scrollbar="false"
>
<view class="home-skeleton-category-row">
<view
v-for="slot in categorySlots"
:key="'category-slot-' + slot"
:class="['home-skeleton-category-pill', 'skeleton-shimmer', slot == 0 ? 'home-skeleton-category-pill-active' : '']"
></view>
</view>
</scroll-view>
</view>
<view class="home-skeleton-body">
<view class="home-skeleton-banner skeleton-shimmer"></view>
<view class="home-skeleton-channel-grid">
<view
v-for="slot in channelSlots"
:key="'channel-slot-' + slot"
class="home-skeleton-channel-card"
>
<view class="home-skeleton-channel-icon skeleton-shimmer"></view>
<view class="home-skeleton-channel-line skeleton-shimmer"></view>
<view class="home-skeleton-channel-line home-skeleton-channel-line-short skeleton-shimmer"></view>
</view>
</view>
<view class="home-skeleton-feed-header">
<view class="home-skeleton-feed-title skeleton-shimmer"></view>
<view class="home-skeleton-feed-subtitle skeleton-shimmer"></view>
</view>
<view class="home-skeleton-product-grid">
<view
v-for="slot in productSlots"
:key="'product-slot-' + slot"
class="home-skeleton-product-card"
>
<view class="home-skeleton-product-image skeleton-shimmer"></view>
<view class="home-skeleton-product-body">
<view class="home-skeleton-product-line skeleton-shimmer"></view>
<view class="home-skeleton-product-line home-skeleton-product-line-short skeleton-shimmer"></view>
<view class="home-skeleton-product-price-row">
<view class="home-skeleton-product-price skeleton-shimmer"></view>
<view class="home-skeleton-product-origin skeleton-shimmer"></view>
</view>
</view>
</view>
</view>
<view class="home-skeleton-safe-bottom" :style="{ height: bottomSafeArea + 88 + 'px' }"></view>
</view>
</view>
</template>
<script setup lang="uts">
import { computed } from 'vue'
const props = defineProps({
statusBarHeight: {
type: Number,
default: 20
},
capsuleRight: {
type: Number,
default: 0
},
bottomSafeArea: {
type: Number,
default: 20
},
showCategoryBar: {
type: Boolean,
default: true
},
categoryCount: {
type: Number,
default: 7
},
channelCount: {
type: Number,
default: 4
},
productCount: {
type: Number,
default: 8
}
})
const capsuleStyle = computed((): string => props.capsuleRight > 0 ? `padding-right:${props.capsuleRight}px;` : '')
function buildSlots(total: number): Array<number> {
const slots: Array<number> = []
for (let i = 0; i < total; i++) {
slots.push(i)
}
return slots
}
const categorySlots = computed((): Array<number> => buildSlots(props.categoryCount))
const channelSlots = computed((): Array<number> => buildSlots(props.channelCount))
const productSlots = computed((): Array<number> => buildSlots(props.productCount))
</script>
<style scoped>
.home-skeleton-root {
min-height: 100%;
background: linear-gradient(180deg, #f6f8fb 0%, #f9fafc 42%, #f4f6f8 100%);
}
.home-skeleton-header {
padding-left: 24rpx;
padding-right: 24rpx;
padding-bottom: 20rpx;
background: linear-gradient(180deg, #f6f8fb 0%, rgba(246, 248, 251, 0.98) 100%);
}
.home-skeleton-tab-row {
flex-direction: row;
align-items: center;
padding-left: 20rpx;
padding-right: 24rpx;
height: 62rpx;
box-sizing: border-box;
padding-top: 6rpx;
padding-bottom: 18rpx;
}
.home-skeleton-tab {
min-width: 132rpx;
height: 56rpx;
border-radius: 999rpx;
align-items: center;
justify-content: center;
background-color: #eceff3;
border-width: 2rpx;
border-style: solid;
border-color: transparent;
padding-left: 24rpx;
padding-right: 24rpx;
box-sizing: border-box;
margin-right: 20rpx;
}
.home-skeleton-tab-active {
border-color: rgba(215, 39, 39, 0.16);
background-color: #fef2f2;
box-shadow: 0 10rpx 22rpx rgba(215, 39, 39, 0.08);
}
.home-skeleton-search {
height: 72rpx;
border-radius: 999rpx;
background-color: #eceff3;
flex-direction: row;
align-items: center;
padding-left: 24rpx;
padding-right: 24rpx;
box-sizing: border-box;
margin-bottom: 18rpx;
box-shadow: 0 10rpx 24rpx rgba(15, 23, 42, 0.04);
}
.home-skeleton-search-icon {
width: 26rpx;
height: 26rpx;
border-radius: 13rpx;
background-color: rgba(176, 184, 196, 0.55);
margin-right: 14rpx;
}
.home-skeleton-search-line {
height: 24rpx;
width: 320rpx;
border-radius: 12rpx;
background-color: rgba(176, 184, 196, 0.45);
}
.home-skeleton-category-scroll {
width: 100%;
white-space: nowrap;
}
.home-skeleton-category-row {
flex-direction: row;
align-items: center;
padding-bottom: 8rpx;
padding-right: 24rpx;
gap: 18rpx;
}
.home-skeleton-category-pill {
width: 112rpx;
height: 44rpx;
border-radius: 999rpx;
background-color: #eceff3;
flex-shrink: 0;
}
.home-skeleton-category-pill-active {
width: 128rpx;
background-color: #f3f4f6;
}
.home-skeleton-body {
padding: 20rpx 24rpx 0;
box-sizing: border-box;
}
.home-skeleton-banner {
height: 180rpx;
border-radius: 28rpx;
background-color: #eceff3;
margin-bottom: 22rpx;
box-shadow: 0 14rpx 28rpx rgba(15, 23, 42, 0.04);
}
.home-skeleton-channel-grid {
flex-direction: row;
flex-wrap: wrap;
justify-content: space-between;
margin-bottom: 28rpx;
row-gap: 18rpx;
}
.home-skeleton-channel-card {
width: 164rpx;
border-radius: 24rpx;
background-color: #ffffff;
padding-top: 24rpx;
padding-bottom: 20rpx;
align-items: center;
box-shadow: 0 10rpx 22rpx rgba(15, 23, 42, 0.04);
}
.home-skeleton-channel-icon {
width: 76rpx;
height: 76rpx;
border-radius: 38rpx;
background-color: #eceff3;
margin-bottom: 14rpx;
}
.home-skeleton-channel-line {
width: 94rpx;
height: 20rpx;
border-radius: 10rpx;
background-color: #eceff3;
margin-top: 8rpx;
}
.home-skeleton-channel-line-short {
width: 66rpx;
}
.home-skeleton-feed-header {
margin-bottom: 22rpx;
}
.home-skeleton-feed-title {
width: 180rpx;
height: 28rpx;
border-radius: 14rpx;
background-color: #eceff3;
margin-bottom: 12rpx;
}
.home-skeleton-feed-subtitle {
width: 240rpx;
height: 20rpx;
border-radius: 10rpx;
background-color: #eceff3;
}
.home-skeleton-product-grid {
flex-direction: row;
flex-wrap: wrap;
justify-content: space-between;
row-gap: 20rpx;
}
.home-skeleton-product-card {
width: 340rpx;
border-radius: 28rpx;
background-color: #ffffff;
overflow: hidden;
box-shadow: 0 12rpx 26rpx rgba(15, 23, 42, 0.05);
}
.home-skeleton-product-image {
height: 340rpx;
background-color: #eceff3;
}
.home-skeleton-product-body {
padding: 18rpx 18rpx 22rpx;
box-sizing: border-box;
}
.home-skeleton-product-line {
height: 24rpx;
border-radius: 12rpx;
background-color: #eceff3;
margin-bottom: 14rpx;
}
.home-skeleton-product-line-short {
width: 72%;
}
.home-skeleton-product-price-row {
flex-direction: row;
align-items: center;
justify-content: space-between;
margin-top: 18rpx;
}
.home-skeleton-product-price {
width: 128rpx;
height: 28rpx;
border-radius: 14rpx;
background-color: #eceff3;
}
.home-skeleton-product-origin {
width: 88rpx;
height: 20rpx;
border-radius: 10rpx;
background-color: #eceff3;
}
.home-skeleton-safe-bottom {
width: 100%;
}
.skeleton-shimmer {
background: linear-gradient(90deg, #edf0f4 0%, #f9fbfd 48%, #edf0f4 100%);
background-size: 220% 100%;
animation: skeleton-shimmer 1.35s ease-in-out infinite;
}
@keyframes skeleton-shimmer {
0% {
background-position: 200% 0;
}
100% {
background-position: -20% 0;
}
}
</style>

View File

@@ -100,9 +100,11 @@
@click="emit('select-product', product)"
>
<view class="hmall-product-image-wrapper hmall-product-image-wrapper-fixed">
<view class="hmall-product-image-placeholder"></view>
<image
class="hmall-product-image"
:class="['hmall-product-image', isProductImageLoaded(product.id) ? 'hmall-product-image-loaded' : '']"
:src="getProductCover(product)"
@load="() => handleProductImageLoad(product.id)"
@error="() => handleProductImageError(product.id)"
mode="aspectFill"
/>
@@ -137,11 +139,11 @@
</view>
</view>
<view v-else-if="loading" class="hmall-loading-state">
<view v-else-if="pageLoading || loading" class="hmall-loading-state">
<text class="hmall-loading-text">正在加载商品...</text>
</view>
<view v-else class="hmall-empty-wrap">
<view v-else-if="!pageLoading" class="hmall-empty-wrap">
<view v-if="emptyStateDescription != ''" class="hmall-empty-state-extended">
<EmptyState :text="emptyStateTitle"></EmptyState>
<text class="hmall-empty-desc">{{ emptyStateDescription }}</text>
@@ -168,6 +170,7 @@ import type { MarketingChannel, ChannelProduct, SimpleCategoryChannel } from '@/
const failedProductImageIds = ref<string[]>([])
const failedChannelImageIds = ref<string[]>([])
const loadedProductImageIds = ref<string[]>([])
type SecondaryCategoryPage = {
id: string
@@ -199,6 +202,10 @@ const props = defineProps({
type: Array<Product>,
default: [] as Array<Product>
},
pageLoading: {
type: Boolean,
default: false
},
loading: {
type: Boolean,
default: false
@@ -339,11 +346,28 @@ function handleProductImageError(productId: string): void {
if (productId == '') {
return
}
handleProductImageLoad(productId)
if (failedProductImageIds.value.indexOf(productId) == -1) {
failedProductImageIds.value.push(productId)
}
}
function handleProductImageLoad(productId: string): void {
if (productId == '') {
return
}
if (loadedProductImageIds.value.indexOf(productId) == -1) {
loadedProductImageIds.value.push(productId)
}
}
function isProductImageLoaded(productId: string): boolean {
if (productId == '') {
return false
}
return loadedProductImageIds.value.indexOf(productId) != -1
}
function getProductTitle(product: Product): string {
if (product.short_title != null && product.short_title != '') {
return product.short_title
@@ -777,6 +801,17 @@ function showMarketPrice(product: Product): boolean {
background: #f2f2f2;
}
.hmall-product-image-placeholder {
position: absolute;
left: 0;
top: 0;
right: 0;
bottom: 0;
background: linear-gradient(90deg, #edf0f4 0%, #f9fbfd 48%, #edf0f4 100%);
background-size: 220% 100%;
animation: hmall-image-shimmer 1.35s ease-in-out infinite;
}
.hmall-product-image {
position: absolute;
top: 0;
@@ -784,6 +819,12 @@ function showMarketPrice(product: Product): boolean {
width: 100%;
height: 100%;
border-radius: 18rpx 18rpx 0 0;
opacity: 0;
transition: opacity 220ms ease;
}
.hmall-product-image-loaded {
opacity: 1;
}
.hmall-product-image-wrapper-fixed {
@@ -795,6 +836,15 @@ function showMarketPrice(product: Product): boolean {
background: #ffffff;
}
@keyframes hmall-image-shimmer {
0% {
background-position: 200% 0;
}
100% {
background-position: -20% 0;
}
}
.hmall-product-body {
padding: 14rpx 14rpx 16rpx;
background: #ffffff;

View File

@@ -4,12 +4,10 @@
<view
v-for="item in modules"
:key="item.key"
class="jd2-module-item"
:class="isServiceModule ? 'jd2-module-item-service' : ''"
:class="['jd2-module-item', activeModule == item.key ? 'jd2-module-item-active' : '', isServiceModule ? 'jd2-module-item-service' : '']"
@click="emit('change-module', item.key)"
>
<text :class="['jd2-module-text', activeModule == item.key ? 'jd2-module-text-active' : '', isServiceModule ? 'jd2-module-text-service' : '']">{{ item.label }}</text>
<view v-if="activeModule == item.key" :class="['jd2-module-line', isServiceModule ? 'jd2-module-line-service' : '']"></view>
</view>
</view>
@@ -121,17 +119,27 @@ function handleSearch() {
padding-right: 24rpx;
height: 62rpx;
box-sizing: border-box;
padding-top: 6rpx;
}
.jd2-module-item {
align-items: center;
justify-content: center;
height: 62rpx;
margin-right: 36rpx;
min-width: 132rpx;
height: 56rpx;
margin-right: 20rpx;
padding-left: 24rpx;
padding-right: 24rpx;
border-radius: 999rpx;
background-color: #eceff3;
border-width: 2rpx;
border-style: solid;
border-color: transparent;
box-sizing: border-box;
}
.jd2-module-item-service {
margin-right: 28rpx;
margin-right: 20rpx;
}
.jd2-module-text {
@@ -155,16 +163,18 @@ function handleSearch() {
color: #0f766e;
}
.jd2-module-item-active {
border-color: rgba(215, 39, 39, 0.16);
background-color: #fef2f2;
box-shadow: 0 10rpx 22rpx rgba(215, 39, 39, 0.08);
}
.jd2-module-line {
margin-top: 8rpx;
width: 32rpx;
height: 6rpx;
border-radius: 999rpx;
background: #e2231a;
display: none;
}
.jd2-module-line-service {
background: #16a085;
display: none;
}
.jd2-search-wrap {