feat: 添加骨架屏加载状态

- detail.vue: 添加资产卡片骨架屏
- strategies.vue: 添加策略列表骨架屏
- me.vue: 添加用户信息和统计卡片骨架屏
- config.vue: 添加表单骨架屏
- 所有页面统一loading状态控制
This commit is contained in:
claw_bot 2026-03-13 13:42:15 +00:00
parent 2d986dd855
commit 12057dc019
4 changed files with 320 additions and 9 deletions

View File

@ -2,8 +2,27 @@
<view class="page-container">
<!-- uView Toast 组件 -->
<u-toast ref="uToastRef" />
<view class="section-card">
<!-- 骨架屏 -->
<view v-if="loading" class="section-card">
<view class="skeleton-form-item" v-for="i in 3" :key="i">
<view class="skeleton-label"></view>
<view class="skeleton-input"></view>
</view>
</view>
<view v-if="loading" class="section-card">
<view class="skeleton-form-item" v-for="i in 2" :key="i">
<view class="skeleton-row">
<view class="skeleton-label-sm"></view>
<view class="skeleton-input-sm"></view>
<view class="skeleton-label-sm"></view>
<view class="skeleton-input-sm"></view>
</view>
</view>
</view>
<!-- 真实内容 -->
<view v-else class="section-card">
<view class="card-header">
<view class="header-icon bg-emerald-100">
<uni-icons type="settings" size="18" color="#064E3B"></uni-icons>
@ -164,6 +183,9 @@ import { api } from '../../utils/api';
const { proxy } = getCurrentInstance();
const uToastRef = ref();
//
const loading = ref(true);
const strategies = ref([]);
const strategyIndex = ref(-1);
//
@ -401,7 +423,9 @@ const submitForm = async () => {
onShow(async () => {
isFetching = true;
loading.value = true;
await fetchStrategies();
loading.value = false;
isFetching = false;
});
</script>
@ -412,7 +436,60 @@ onShow(async () => {
min-height: 100vh;
background-color: #F3F4F6;
padding-bottom: 200rpx;
/* 给底部按钮留空 */
}
/* 骨架屏样式 */
.skeleton-form-item {
margin-bottom: 32rpx;
}
.skeleton-label {
width: 120rpx;
height: 26rpx;
background: linear-gradient(90deg, #E5E7EB 25%, #F3F4F6 37%, #E5E7EB 50%);
background-size: 400% 100%;
animation: skeleton-loading 1.8s ease infinite;
border-radius: 8rpx;
margin-bottom: 16rpx;
}
.skeleton-label-sm {
width: 80rpx;
height: 22rpx;
background: linear-gradient(90deg, #E5E7EB 25%, #F3F4F6 37%, #E5E7EB 50%);
background-size: 400% 100%;
animation: skeleton-loading 1.8s ease infinite;
border-radius: 6rpx;
margin-bottom: 12rpx;
}
.skeleton-input {
width: 100%;
height: 96rpx;
background: linear-gradient(90deg, #F9FAFB 25%, #F3F4F6 37%, #F9FAFB 50%);
background-size: 400% 100%;
animation: skeleton-loading 1.8s ease infinite;
border-radius: 20rpx;
}
.skeleton-input-sm {
flex: 1;
height: 72rpx;
background: linear-gradient(90deg, #F9FAFB 25%, #F3F4F6 37%, #F9FAFB 50%);
background-size: 400% 100%;
animation: skeleton-loading 1.8s ease infinite;
border-radius: 16rpx;
}
.skeleton-row {
display: flex;
gap: 24rpx;
flex-direction: column;
}
@keyframes skeleton-loading {
0% { background-position: 100% 50% }
100% { background-position: 0 50% }
}
.flex-row {

View File

@ -3,8 +3,25 @@
<!-- uView Toast 组件 -->
<u-toast ref="uToastRef" />
<view class="header-section">
<!-- 骨架屏资产卡片 -->
<view class="header-section" v-if="loading">
<view class="skeleton-card">
<view class="skeleton-row">
<view class="skeleton-text skeleton-label"></view>
<view class="skeleton-text skeleton-badge"></view>
</view>
<view class="skeleton-row">
<view class="skeleton-text skeleton-big"></view>
</view>
<view class="skeleton-row skeleton-bottom">
<view class="skeleton-text skeleton-stat"></view>
<view class="skeleton-text skeleton-stat"></view>
</view>
</view>
</view>
<!-- 真实内容资产卡片 -->
<view class="header-section" v-else>
<view class="asset-card">
<view class="card-watermark">
<uni-icons type="vip-filled" size="120" color="rgba(255,255,255,0.05)"></uni-icons>
@ -367,6 +384,9 @@ import { api } from '../../utils/api';
const { proxy } = getCurrentInstance();
const uToastRef = ref();
//
const loading = ref(true);
//
const getCurrencySymbol = (currency) => {
const symbols = {
@ -541,8 +561,10 @@ const fetchTransactions = async () => {
};
onMounted(async () => {
loading.value = true;
await fetchPortfolioData();
await fetchTransactions();
loading.value = false;
});
const goStrategyConfig = () => {
@ -712,9 +734,62 @@ const deletePortfolio = async () => {
.page-container {
min-height: 100vh;
background-color: #F9FAFB;
/* 关键:底部留出空间,防止内容被固定按钮遮挡 */
padding-bottom: 180rpx;
}
/* 骨架屏样式 */
.skeleton-card {
background-color: #064E3B;
border-radius: 40rpx;
padding: 40rpx 48rpx;
min-height: 320rpx;
}
.skeleton-row {
display: flex;
align-items: center;
margin-bottom: 20rpx;
}
.skeleton-bottom {
justify-content: space-between;
margin-bottom: 0;
}
.skeleton-text {
background: linear-gradient(90deg, rgba(255,255,255,0.1) 25%, rgba(255,255,255,0.2) 37%, rgba(255,255,255,0.1) 50%);
background-size: 400% 100%;
animation: skeleton-loading 1.8s ease infinite;
border-radius: 8rpx;
}
.skeleton-label {
width: 160rpx;
height: 28rpx;
}
.skeleton-badge {
width: 120rpx;
height: 40rpx;
border-radius: 20rpx;
}
.skeleton-big {
width: 350rpx;
height: 68rpx;
}
.skeleton-stat {
width: 150rpx;
height: 36rpx;
}
@keyframes skeleton-loading {
0% { background-position: 100% 50% }
100% { background-position: 0 50% }
}
/* 工具类 */
.flex-row { display: flex; flex-direction: row; }
.flex-col { display: flex; flex-direction: column; }
.items-center { align-items: center; }

View File

@ -1,6 +1,20 @@
<template>
<view class="page-container">
<view class="profile-header">
<!-- 骨架屏 -->
<view v-if="loading" class="profile-header">
<view class="skeleton-avatar"></view>
<view class="skeleton-name"></view>
<view class="skeleton-info"></view>
</view>
<view v-if="loading" class="stats-grid">
<view class="stat-card" v-for="i in 4" :key="i">
<view class="skeleton-text skeleton-label"></view>
<view class="skeleton-text skeleton-value"></view>
</view>
</view>
<!-- 真实内容 -->
<view v-if="!loading" class="profile-header">
<view class="avatar-container">
<!-- 如果有头像URL使用图片显示 -->
<image v-if="userInfo.avatar" :src="userInfo.avatar" class="avatar-image" mode="aspectFill"></image>
@ -12,7 +26,13 @@
<text v-if="userInfo.email" class="user-email">{{ userInfo.email }}</text>
</view>
<view class="stats-grid">
<view v-if="loading" class="stats-grid">
<view class="stat-card" v-for="i in 4" :key="i">
<view class="skeleton-text skeleton-label"></view>
<view class="skeleton-text skeleton-value"></view>
</view>
</view>
<view v-else class="stats-grid">
<view class="stat-card">
<text class="stat-label">已记录事件</text>
<text class="stat-val">{{ userStats.signalsCaptured?.toLocaleString() || 0 }}</text>
@ -61,6 +81,9 @@
<script setup>
import { ref, onMounted, getCurrentInstance } from 'vue';
//
const loading = ref(true);
//
const userInfo = ref({
userName: '',
@ -124,10 +147,12 @@ const fetchUserStats = async () => {
onMounted(async () => {
console.log('个人中心页面加载,开始获取数据...');
loading.value = true;
await Promise.all([
fetchUserInfo(),
fetchUserStats()
]);
loading.value = false;
});
</script>
@ -137,8 +162,62 @@ onMounted(async () => {
padding: 40rpx;
padding-top: 100rpx;
background-color: #F9FAFB;
/* 浅灰色背景,与其他页面保持一致 */
}
/* 骨架屏样式 */
.skeleton-avatar {
width: 160rpx;
height: 160rpx;
border-radius: 50%;
background: linear-gradient(90deg, #E5E7EB 25%, #F3F4F6 37%, #E5E7EB 50%);
background-size: 400% 100%;
animation: skeleton-loading 1.8s ease infinite;
margin-bottom: 24rpx;
}
.skeleton-name {
width: 200rpx;
height: 40rpx;
background: linear-gradient(90deg, #E5E7EB 25%, #F3F4F6 37%, #E5E7EB 50%);
background-size: 400% 100%;
animation: skeleton-loading 1.8s ease infinite;
border-radius: 8rpx;
margin-bottom: 16rpx;
}
.skeleton-info {
width: 300rpx;
height: 24rpx;
background: linear-gradient(90deg, #E5E7EB 25%, #F3F4F6 37%, #E5E7EB 50%);
background-size: 400% 100%;
animation: skeleton-loading 1.8s ease infinite;
border-radius: 8rpx;
}
.skeleton-text {
background: linear-gradient(90deg, #E5E7EB 25%, #F3F4F6 37%, #E5E7EB 50%);
background-size: 400% 100%;
animation: skeleton-loading 1.8s ease infinite;
border-radius: 8rpx;
}
.skeleton-label {
width: 100rpx;
height: 20rpx;
margin: 0 auto 8rpx;
}
.skeleton-value {
width: 80rpx;
height: 36rpx;
margin: 0 auto;
}
@keyframes skeleton-loading {
0% { background-position: 100% 50% }
100% { background-position: 0 50% }
}
.profile-header { display: flex; flex-direction: column; align-items: center; margin-bottom: 60rpx; }
.avatar-container { position: relative; margin-bottom: 24rpx; }
.avatar-circle { width: 160rpx; height: 160rpx; border-radius: 50%; background-color: #E5E7EB; border: 4rpx solid #fff; box-shadow: 0 10rpx 20rpx rgba(0,0,0,0.1); }

View File

@ -17,8 +17,24 @@
</view>
<view class="strategy-list">
<!-- 骨架屏 -->
<view v-if="loading" class="strategy-card" v-for="i in 2" :key="'skeleton-' + i">
<view class="skeleton-row">
<view class="skeleton-icon"></view>
<view class="skeleton-content">
<view class="skeleton-text skeleton-title"></view>
<view class="skeleton-text skeleton-tag"></view>
</view>
</view>
<view class="skeleton-text skeleton-desc"></view>
<view class="skeleton-footer">
<view class="skeleton-text skeleton-btn"></view>
</view>
</view>
<!-- 真实内容 -->
<view
v-else
class="strategy-card"
v-for="(item, index) in strategies"
:key="index"
@ -67,6 +83,7 @@ import { onShow } from '@dcloudio/uni-app';
const { proxy } = getCurrentInstance();
const api = proxy.$api;
const loading = ref(true);
const strategies = ref([]);
//
@ -97,7 +114,9 @@ const fetchStrategies = async () => {
onShow(async () => {
isFetching = true;
loading.value = true;
await fetchStrategies();
loading.value = false;
isFetching = false;
});
@ -149,6 +168,67 @@ const handleAction = (item) => {
}
.add-btn-box:active { background-color: #F3F4F6; }
/* 骨架屏样式 */
.skeleton-row {
display: flex;
align-items: center;
gap: 24rpx;
margin-bottom: 24rpx;
}
.skeleton-icon {
width: 80rpx;
height: 80rpx;
border-radius: 20rpx;
background: linear-gradient(90deg, #F1F2F4 25%, #e6e6e6 37%, #F1F2F4 50%);
background-size: 400% 100%;
animation: skeleton-loading 1.8s ease infinite;
}
.skeleton-content {
flex: 1;
}
.skeleton-text {
background: linear-gradient(90deg, #F1F2F4 25%, #e6e6e6 37%, #F1F2F4 50%);
background-size: 400% 100%;
animation: skeleton-loading 1.8s ease infinite;
border-radius: 8rpx;
}
.skeleton-title {
width: 200rpx;
height: 32rpx;
margin-bottom: 12rpx;
}
.skeleton-tag {
width: 100rpx;
height: 22rpx;
}
.skeleton-desc {
width: 100%;
height: 60rpx;
margin-bottom: 24rpx;
}
.skeleton-footer {
display: flex;
justify-content: flex-end;
}
.skeleton-btn {
width: 120rpx;
height: 64rpx;
border-radius: 32rpx;
}
@keyframes skeleton-loading {
0% { background-position: 100% 50% }
100% { background-position: 0 50% }
}
/* 策略卡片 */
.strategy-card {
background-color: #FFFFFF;