255 lines
5.2 KiB
Vue
Raw Permalink Normal View History

2025-07-19 20:00:08 +08:00
<template>
<view class="stat-card" :class="{ 'is-loading': loading }">
<view class="card-header" v-if="title">
<text class="card-title">{{ title }}</text>
<uni-icons v-if="icon" :type="icon" size="20" :color="iconColor" />
</view>
<view class="stats-grid" :class="gridClass">
<view
v-for="(item, index) in stats"
:key="index"
class="stat-item"
:class="{ 'clickable': item.clickable }"
@click="handleStatClick(item, index)">
<view class="stat-icon" v-if="item.icon" :style="{ backgroundColor: item.color }">
<uni-icons :type="item.icon" size="24" color="#fff" />
</view>
<view class="stat-content">
<text class="stat-value" :style="{ color: item.color }">
{{ loading ? '--' : (item.value || 0) }}
</text>
<text class="stat-label">{{ item.label }}</text>
<text v-if="item.trend" class="stat-trend" :class="item.trend.type">
{{ item.trend.text }}
</text>
</view>
</view>
</view>
<!-- 加载骨架屏 -->
<view v-if="loading" class="loading-skeleton">
<view v-for="n in 3" :key="n" class="skeleton-item">
<view class="skeleton-circle"></view>
<view class="skeleton-lines">
<view class="skeleton-line"></view>
<view class="skeleton-line short"></view>
</view>
</view>
</view>
</view>
</template>
<script>
export default {
name: 'StatCard',
props: {
title: String,
icon: String,
iconColor: {
type: String,
default: '#666'
},
stats: {
type: Array,
default: () => []
},
loading: {
type: Boolean,
default: false
}
},
computed: {
gridClass() {
return `cols-${this.stats.length}`
}
},
methods: {
handleStatClick(item, index) {
if (item.clickable) {
this.$emit('stat-click', { item, index })
}
}
}
}
</script>
<style lang="scss" scoped>
.stat-card {
background: #fff;
border-radius: 16rpx;
padding: 30rpx;
margin: 20rpx;
box-shadow: 0 4rpx 20rpx rgba(0, 0, 0, 0.08);
position: relative;
overflow: hidden;
&::before {
content: '';
position: absolute;
top: 0;
left: 0;
right: 0;
height: 6rpx;
background: linear-gradient(90deg, #667eea, #764ba2);
}
.card-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 30rpx;
.card-title {
font-size: 32rpx;
font-weight: 600;
color: #333;
}
}
.stats-grid {
display: flex;
&.cols-2 .stat-item {
flex: 0 0 50%;
}
&.cols-3 .stat-item {
flex: 0 0 33.333%;
}
&.cols-4 .stat-item {
flex: 0 0 25%;
}
}
.stat-item {
display: flex;
flex-direction: column;
align-items: center;
padding: 20rpx 10rpx;
border-radius: 12rpx;
transition: all 0.3s ease;
&.clickable {
cursor: pointer;
&:hover {
background: #f8f9fa;
transform: translateY(-2rpx);
}
}
.stat-icon {
width: 60rpx;
height: 60rpx;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 16rpx;
}
.stat-content {
text-align: center;
.stat-value {
display: block;
font-size: 36rpx;
font-weight: 700;
line-height: 1.2;
margin-bottom: 8rpx;
}
.stat-label {
display: block;
font-size: 24rpx;
color: #666;
line-height: 1.2;
}
.stat-trend {
display: block;
font-size: 20rpx;
margin-top: 4rpx;
&.up {
color: #52c41a;
}
&.down {
color: #ff4d4f;
}
&.stable {
color: #faad14;
}
}
}
}
// 加载状态
&.is-loading {
.stats-grid {
opacity: 0.3;
}
}
.loading-skeleton {
position: absolute;
top: 80rpx;
left: 30rpx;
right: 30rpx;
display: flex;
.skeleton-item {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
margin: 0 10rpx;
.skeleton-circle {
width: 60rpx;
height: 60rpx;
border-radius: 50%;
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: loading 1.5s infinite;
margin-bottom: 16rpx;
}
.skeleton-lines {
width: 100%;
.skeleton-line {
height: 24rpx;
background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%);
background-size: 200% 100%;
animation: loading 1.5s infinite;
border-radius: 4rpx;
margin-bottom: 8rpx;
&.short {
width: 60%;
margin: 0 auto;
}
}
}
}
}
}
@keyframes loading {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
}
</style>