332 lines
7.6 KiB
Vue
332 lines
7.6 KiB
Vue
<template>
|
|
<div
|
|
v-motion
|
|
:initial="{ opacity: 0, y: 20, scale: 0.95 }"
|
|
:enter="{
|
|
opacity: 1,
|
|
y: 0,
|
|
scale: 1,
|
|
transition: {
|
|
delay: delay,
|
|
duration: 600,
|
|
type: 'spring',
|
|
stiffness: 200,
|
|
damping: 20
|
|
}
|
|
}"
|
|
:hovered="{
|
|
scale: 1.02,
|
|
y: -2,
|
|
transition: {
|
|
duration: 200
|
|
}
|
|
}"
|
|
class="stats-card"
|
|
>
|
|
<div class="stats-card-inner">
|
|
<!-- Icon Section -->
|
|
<div class="stats-icon" :style="{ background: iconBackground }">
|
|
<v-icon :color="iconColor" size="24">{{ icon }}</v-icon>
|
|
</div>
|
|
|
|
<!-- Content Section -->
|
|
<div class="stats-content">
|
|
<p class="stats-label">{{ label }}</p>
|
|
<div class="stats-value-wrapper">
|
|
<h3
|
|
class="stats-value"
|
|
v-motion
|
|
:initial="{ opacity: 0 }"
|
|
:visible="{
|
|
opacity: 1,
|
|
transition: {
|
|
delay: delay + 200,
|
|
duration: 800
|
|
}
|
|
}"
|
|
>
|
|
<span v-if="prefix">{{ prefix }}</span>
|
|
<AnimatedNumber :value="value" :duration="1500" :format="formatNumber" />
|
|
<span v-if="suffix">{{ suffix }}</span>
|
|
</h3>
|
|
<div
|
|
v-if="change !== undefined"
|
|
class="stats-change"
|
|
:class="changeClass"
|
|
v-motion
|
|
:initial="{ opacity: 0, scale: 0.8 }"
|
|
:visible="{
|
|
opacity: 1,
|
|
scale: 1,
|
|
transition: {
|
|
delay: delay + 400,
|
|
duration: 500,
|
|
type: 'spring'
|
|
}
|
|
}"
|
|
>
|
|
<v-icon size="16">
|
|
{{ change >= 0 ? 'mdi-trending-up' : 'mdi-trending-down' }}
|
|
</v-icon>
|
|
<span>{{ Math.abs(change) }}%</span>
|
|
</div>
|
|
</div>
|
|
<p v-if="subtitle" class="stats-subtitle">{{ subtitle }}</p>
|
|
</div>
|
|
|
|
<!-- Background Decoration -->
|
|
<div class="stats-decoration">
|
|
<svg viewBox="0 0 200 100" class="stats-chart">
|
|
<path
|
|
:d="sparklinePath"
|
|
fill="none"
|
|
:stroke="decorationColor"
|
|
stroke-width="2"
|
|
stroke-linecap="round"
|
|
opacity="0.2"
|
|
/>
|
|
<path
|
|
:d="sparklinePath"
|
|
fill="url(#gradient)"
|
|
opacity="0.1"
|
|
/>
|
|
<defs>
|
|
<linearGradient id="gradient" x1="0%" y1="0%" x2="0%" y2="100%">
|
|
<stop offset="0%" :stop-color="decorationColor" stop-opacity="0.3" />
|
|
<stop offset="100%" :stop-color="decorationColor" stop-opacity="0" />
|
|
</linearGradient>
|
|
</defs>
|
|
</svg>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { computed, ref, onMounted } from 'vue';
|
|
|
|
interface Props {
|
|
label: string;
|
|
value: number;
|
|
icon: string;
|
|
iconColor?: string;
|
|
iconBackground?: string;
|
|
change?: number;
|
|
prefix?: string;
|
|
suffix?: string;
|
|
subtitle?: string;
|
|
delay?: number;
|
|
decorationColor?: string;
|
|
}
|
|
|
|
const props = withDefaults(defineProps<Props>(), {
|
|
iconColor: 'error',
|
|
iconBackground: 'linear-gradient(135deg, rgba(220, 38, 38, 0.1), rgba(220, 38, 38, 0.05))',
|
|
delay: 0,
|
|
decorationColor: '#dc2626'
|
|
});
|
|
|
|
// Animated number component
|
|
const AnimatedNumber = {
|
|
props: {
|
|
value: Number,
|
|
duration: { type: Number, default: 1000 },
|
|
format: Function
|
|
},
|
|
setup(props: any) {
|
|
const displayValue = ref(0);
|
|
|
|
onMounted(() => {
|
|
const startTime = Date.now();
|
|
const startValue = 0;
|
|
const endValue = props.value;
|
|
|
|
const updateValue = () => {
|
|
const now = Date.now();
|
|
const progress = Math.min((now - startTime) / props.duration, 1);
|
|
|
|
// Easing function for smooth animation
|
|
const easeOutQuart = 1 - Math.pow(1 - progress, 4);
|
|
displayValue.value = startValue + (endValue - startValue) * easeOutQuart;
|
|
|
|
if (progress < 1) {
|
|
requestAnimationFrame(updateValue);
|
|
} else {
|
|
displayValue.value = endValue;
|
|
}
|
|
};
|
|
|
|
updateValue();
|
|
});
|
|
|
|
const formattedValue = computed(() => {
|
|
if (props.format) {
|
|
return props.format(displayValue.value);
|
|
}
|
|
return Math.round(displayValue.value).toLocaleString();
|
|
});
|
|
|
|
return () => formattedValue.value;
|
|
}
|
|
};
|
|
|
|
// Compute change indicator class
|
|
const changeClass = computed(() => {
|
|
if (props.change === undefined) return '';
|
|
return props.change >= 0 ? 'stats-change--positive' : 'stats-change--negative';
|
|
});
|
|
|
|
// Format number function
|
|
const formatNumber = (num: number) => {
|
|
if (num >= 1000000) {
|
|
return (num / 1000000).toFixed(1) + 'M';
|
|
} else if (num >= 1000) {
|
|
return (num / 1000).toFixed(1) + 'K';
|
|
}
|
|
return Math.round(num).toLocaleString();
|
|
};
|
|
|
|
// Generate random sparkline path
|
|
const sparklinePath = computed(() => {
|
|
const points = 10;
|
|
const width = 200;
|
|
const height = 100;
|
|
const values = Array.from({ length: points }, () => Math.random() * 0.6 + 0.2);
|
|
|
|
const path = values.map((value, index) => {
|
|
const x = (index / (points - 1)) * width;
|
|
const y = height - (value * height);
|
|
return `${index === 0 ? 'M' : 'L'} ${x} ${y}`;
|
|
}).join(' ');
|
|
|
|
return `${path} L ${width} ${height} L 0 ${height} Z`;
|
|
});
|
|
</script>
|
|
|
|
<style scoped lang="scss">
|
|
.stats-card {
|
|
position: relative;
|
|
height: 100%;
|
|
background: linear-gradient(135deg,
|
|
rgba(255, 255, 255, 0.9),
|
|
rgba(255, 255, 255, 0.7)
|
|
);
|
|
backdrop-filter: blur(20px);
|
|
-webkit-backdrop-filter: blur(20px);
|
|
border-radius: 1rem;
|
|
border: 1px solid rgba(255, 255, 255, 0.3);
|
|
box-shadow:
|
|
0 8px 32px rgba(0, 0, 0, 0.08),
|
|
inset 0 1px 0 rgba(255, 255, 255, 0.5);
|
|
overflow: hidden;
|
|
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
|
cursor: pointer;
|
|
|
|
&:hover {
|
|
box-shadow:
|
|
0 12px 40px rgba(0, 0, 0, 0.12),
|
|
inset 0 1px 0 rgba(255, 255, 255, 0.7);
|
|
}
|
|
}
|
|
|
|
.stats-card-inner {
|
|
position: relative;
|
|
padding: 1.5rem;
|
|
height: 100%;
|
|
display: flex;
|
|
flex-direction: column;
|
|
z-index: 1;
|
|
}
|
|
|
|
.stats-icon {
|
|
width: 48px;
|
|
height: 48px;
|
|
border-radius: 12px;
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: center;
|
|
margin-bottom: 1rem;
|
|
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.05);
|
|
}
|
|
|
|
.stats-content {
|
|
flex: 1;
|
|
display: flex;
|
|
flex-direction: column;
|
|
}
|
|
|
|
.stats-label {
|
|
font-size: 0.875rem;
|
|
color: rgb(107, 114, 128);
|
|
margin: 0 0 0.5rem 0;
|
|
font-weight: 500;
|
|
}
|
|
|
|
.stats-value-wrapper {
|
|
display: flex;
|
|
align-items: baseline;
|
|
gap: 0.75rem;
|
|
margin-bottom: 0.25rem;
|
|
}
|
|
|
|
.stats-value {
|
|
font-size: 2rem;
|
|
font-weight: 700;
|
|
margin: 0;
|
|
background: linear-gradient(135deg, #dc2626, #b91c1c);
|
|
-webkit-background-clip: text;
|
|
-webkit-text-fill-color: transparent;
|
|
background-clip: text;
|
|
line-height: 1.2;
|
|
}
|
|
|
|
.stats-change {
|
|
display: inline-flex;
|
|
align-items: center;
|
|
gap: 0.25rem;
|
|
padding: 0.25rem 0.5rem;
|
|
border-radius: 9999px;
|
|
font-size: 0.75rem;
|
|
font-weight: 600;
|
|
|
|
&--positive {
|
|
background: rgba(34, 197, 94, 0.1);
|
|
color: rgb(34, 197, 94);
|
|
}
|
|
|
|
&--negative {
|
|
background: rgba(239, 68, 68, 0.1);
|
|
color: rgb(239, 68, 68);
|
|
}
|
|
}
|
|
|
|
.stats-subtitle {
|
|
font-size: 0.75rem;
|
|
color: rgb(156, 163, 175);
|
|
margin: 0.25rem 0 0 0;
|
|
}
|
|
|
|
.stats-decoration {
|
|
position: absolute;
|
|
bottom: 0;
|
|
right: 0;
|
|
width: 60%;
|
|
height: 50%;
|
|
pointer-events: none;
|
|
}
|
|
|
|
.stats-chart {
|
|
width: 100%;
|
|
height: 100%;
|
|
}
|
|
|
|
@media (max-width: 640px) {
|
|
.stats-value {
|
|
font-size: 1.5rem;
|
|
}
|
|
|
|
.stats-card-inner {
|
|
padding: 1rem;
|
|
}
|
|
}
|
|
</style> |