feat(ui): UI/交互优化 — 动画系统+Design Token+骨架屏+微反馈
1. Design Token 统一:CategoryIcon/BudgetBar/Numpad/Skeleton/TransactionItem 硬编码hex颜色替换为 $primary/$border/$danger 等SCSS变量 2. 入场动画:mixins.scss 添加 fade-in-up/bounce-in/count-up 等 mixin+keyframes, 首页卡片/金额/快捷按钮应用交错入场动画 3. 记账成功动画增强:SaveSuccess 增加纸屑粒子爆发+光环脉冲效果, prefers-reduced-motion 下自动隐藏 4. 骨架屏替代文字加载:recurring/group-manage/notifications 三个页面 从"加载中..."替换为骨架屏;空状态增加图标容器+引导文案 5. 交互微反馈:分类按压0.92缩放、推荐虚线呼吸动画、 Tab滑块cubic-bezier优化、快捷按钮弹性按压 6. 左滑删除提示:TransactionItem首项1.5s后自动微微左滑再回弹, 引导用户发现滑动删除功能
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<view class="budget-bar-wrap">
|
||||
<view class="bar-track">
|
||||
<view class="bar-fill" :style="{ width: percentage + '%', background: fillColor }"></view>
|
||||
<view class="bar-fill" :class="{ over: rawPercentage > 100 }" :style="{ width: percentage + '%' }"></view>
|
||||
</view>
|
||||
<view class="bar-info" v-if="showLabel">
|
||||
<text class="bar-text" v-if="rawPercentage <= 100">预算 {{ formatAmount(total) }} · 剩余 {{ formatAmount(Math.max(0, total - used)) }}</text>
|
||||
@@ -27,25 +27,25 @@ const rawPercentage = computed(() => {
|
||||
})
|
||||
|
||||
const percentage = computed(() => Math.min(100, rawPercentage.value))
|
||||
|
||||
const fillColor = computed(() => {
|
||||
if (rawPercentage.value > 100) return '#FF6B6B'
|
||||
return 'linear-gradient(90deg, #FF8C69, #FFB89A)'
|
||||
})
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.bar-track {
|
||||
height: 16rpx;
|
||||
border-radius: 8rpx;
|
||||
background: #F0E0D6;
|
||||
background: $border;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.bar-fill {
|
||||
height: 100%;
|
||||
border-radius: 8rpx;
|
||||
transition: width 0.6s ease-out;
|
||||
background: linear-gradient(90deg, $primary, lighten($primary, 15%));
|
||||
transition: width 0.6s cubic-bezier(0.4, 0, 0.2, 1);
|
||||
|
||||
&.over {
|
||||
background: $danger;
|
||||
}
|
||||
}
|
||||
|
||||
.bar-info {
|
||||
@@ -56,20 +56,20 @@ const fillColor = computed(() => {
|
||||
|
||||
.bar-text {
|
||||
font-size: 24rpx;
|
||||
color: #8B7E7E;
|
||||
color: $text-sec;
|
||||
}
|
||||
|
||||
.bar-pct {
|
||||
font-family: 'Fredoka', sans-serif;
|
||||
font-size: 28rpx;
|
||||
font-weight: 500;
|
||||
color: #FF8C69;
|
||||
color: $primary;
|
||||
|
||||
&.over { color: #FF6B6B; }
|
||||
&.over { color: $danger; }
|
||||
}
|
||||
|
||||
.bar-text.over {
|
||||
color: #FF6B6B;
|
||||
color: $danger;
|
||||
font-weight: 500;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -22,6 +22,7 @@ const iconStyle = computed(() => ({
|
||||
background: props.selected ? '#FFE8E0' : (props.bgColor || (props.color + '15')),
|
||||
width: (sizeMap[props.size || 'md']) + 'rpx',
|
||||
height: (sizeMap[props.size || 'md']) + 'rpx',
|
||||
boxShadow: props.selected ? '0 0 0 4rpx rgba(255, 140, 105, 0.15)' : 'none',
|
||||
}))
|
||||
|
||||
const fontSize = computed(() => (fontMap[props.size || 'md']) + 'rpx')
|
||||
@@ -42,6 +43,7 @@ const fontSize = computed(() => (fontMap[props.size || 'md']) + 'rpx')
|
||||
}
|
||||
|
||||
.selected {
|
||||
border-color: #FF8C69;
|
||||
border-color: $primary;
|
||||
transform: scale(1.05);
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -207,10 +207,10 @@ function onConfirm() {
|
||||
|
||||
.key.fn {
|
||||
background: $primary-light;
|
||||
border-color: #FFD0C0;
|
||||
border-color: darken($primary-light, 8%);
|
||||
|
||||
&:active {
|
||||
background: #FFD0C0;
|
||||
background: darken($primary-light, 8%);
|
||||
box-shadow: $shadow-clay-pressed;
|
||||
transform: scale(0.96);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,22 @@
|
||||
<template>
|
||||
<view class="save-success" v-if="visible">
|
||||
<!-- 纸屑粒子 -->
|
||||
<view class="confetti-container" v-if="animating">
|
||||
<view
|
||||
v-for="i in 20"
|
||||
:key="i"
|
||||
class="confetti"
|
||||
:class="`confetti-${i}`"
|
||||
:style="confettiStyle(i)"
|
||||
></view>
|
||||
</view>
|
||||
|
||||
<view class="success-content" :class="{ show: animating }">
|
||||
<view class="success-icon">
|
||||
<Icon name="check" :size="80" color="#FFFFFF" />
|
||||
<view class="success-icon-wrap">
|
||||
<view class="success-ring"></view>
|
||||
<view class="success-icon">
|
||||
<Icon name="check" :size="80" color="#FFFFFF" />
|
||||
</view>
|
||||
</view>
|
||||
<text class="success-text">{{ text }}</text>
|
||||
<view class="success-actions" v-if="showContinue">
|
||||
@@ -31,12 +45,33 @@ const emit = defineEmits(['update:visible', 'continue', 'back'])
|
||||
|
||||
const animating = ref(false)
|
||||
|
||||
// 纸屑样式:随机位置、颜色、动画延迟
|
||||
const confettiColors = ['#FF8C69', '#FFD166', '#7BC67E', '#6C9BCF', '#FF69B4', '#FFB89A']
|
||||
function confettiStyle(i: number) {
|
||||
const angle = (i / 20) * 360
|
||||
const distance = 120 + Math.random() * 200
|
||||
const color = confettiColors[i % confettiColors.length]
|
||||
const delay = Math.random() * 0.3
|
||||
const size = 8 + Math.random() * 12
|
||||
const isRect = i % 3 === 0
|
||||
return {
|
||||
'--tx': `${Math.cos(angle * Math.PI / 180) * distance}rpx`,
|
||||
'--ty': `${Math.sin(angle * Math.PI / 180) * distance - 100}rpx`,
|
||||
'--rot': `${Math.random() * 720 - 360}deg`,
|
||||
background: color,
|
||||
width: isRect ? `${size}rpx` : `${size * 0.6}rpx`,
|
||||
height: `${size}rpx`,
|
||||
borderRadius: isRect ? '2rpx' : '50%',
|
||||
animationDelay: `${delay}s`,
|
||||
}
|
||||
}
|
||||
|
||||
watch(() => props.visible, (val) => {
|
||||
if (val) {
|
||||
setTimeout(() => { animating.value = true }, 50)
|
||||
// 如果不显示继续按钮,自动关闭
|
||||
if (!props.showContinue) {
|
||||
setTimeout(() => { onBack() }, 2000)
|
||||
setTimeout(() => { onBack() }, 2200)
|
||||
}
|
||||
} else {
|
||||
animating.value = false
|
||||
@@ -74,13 +109,44 @@ function onBack() {
|
||||
z-index: 9999;
|
||||
}
|
||||
|
||||
// 纸屑容器
|
||||
.confetti-container {
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
width: 0;
|
||||
height: 0;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.confetti {
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
animation: confetti-burst 0.8s cubic-bezier(0.23, 1, 0.32, 1) forwards;
|
||||
}
|
||||
|
||||
@keyframes confetti-burst {
|
||||
0% {
|
||||
transform: translate(0, 0) rotate(0deg) scale(0);
|
||||
opacity: 0;
|
||||
}
|
||||
20% {
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
transform: translate(var(--tx), var(--ty)) rotate(var(--rot)) scale(1);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.success-content {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
transform: scale(0.8);
|
||||
opacity: 0;
|
||||
transition: all 0.3s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
transition: all 0.4s cubic-bezier(0.34, 1.56, 0.64, 1);
|
||||
z-index: 2;
|
||||
|
||||
&.show {
|
||||
transform: scale(1);
|
||||
@@ -88,12 +154,42 @@ function onBack() {
|
||||
}
|
||||
}
|
||||
|
||||
// 图标外环脉冲
|
||||
.success-icon-wrap {
|
||||
position: relative;
|
||||
@include flex-center;
|
||||
}
|
||||
|
||||
.success-ring {
|
||||
position: absolute;
|
||||
width: 200rpx;
|
||||
height: 200rpx;
|
||||
border-radius: 50%;
|
||||
border: 4rpx solid rgba(123, 198, 126, 0.3);
|
||||
animation: ring-pulse 1s ease-out 0.3s both;
|
||||
}
|
||||
|
||||
@keyframes ring-pulse {
|
||||
0% {
|
||||
transform: scale(0.8);
|
||||
opacity: 0;
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
transform: scale(1.5);
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.success-icon {
|
||||
width: 160rpx;
|
||||
height: 160rpx;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, $success, #5BA85E);
|
||||
background: linear-gradient(135deg, $success, darken($success, 10%));
|
||||
@include flex-center;
|
||||
box-shadow: 0 8rpx 32rpx rgba(123, 198, 126, 0.4);
|
||||
}
|
||||
|
||||
.success-text {
|
||||
@@ -114,15 +210,16 @@ function onBack() {
|
||||
border-radius: $radius-xl;
|
||||
min-width: 200rpx;
|
||||
@include flex-center;
|
||||
transition: transform $transition-fast, opacity $transition-fast;
|
||||
|
||||
&.continue {
|
||||
background: $primary;
|
||||
&:active { opacity: 0.8; }
|
||||
&:active { opacity: 0.8; transform: scale(0.96); }
|
||||
}
|
||||
|
||||
&.back {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
&:active { background: rgba(255, 255, 255, 0.3); }
|
||||
&:active { background: rgba(255, 255, 255, 0.3); transform: scale(0.96); }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -131,4 +228,10 @@ function onBack() {
|
||||
font-weight: 600;
|
||||
color: $surface;
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.confetti { animation: none !important; display: none; }
|
||||
.success-ring { animation: none !important; display: none; }
|
||||
.success-content { transition: opacity 0.2s ease; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -19,7 +19,7 @@ const style = computed(() => ({
|
||||
|
||||
<style lang="scss" scoped>
|
||||
.skeleton {
|
||||
background: linear-gradient(90deg, #F0E0D6 25%, #F8EDE4 50%, #F0E0D6 75%);
|
||||
background: linear-gradient(90deg, $border 25%, $surface-warm 50%, $border 75%);
|
||||
background-size: 200% 100%;
|
||||
animation: shimmer 1.5s infinite;
|
||||
border-radius: 8rpx;
|
||||
|
||||
@@ -108,6 +108,8 @@ function onTouchEnd() {
|
||||
</script>
|
||||
|
||||
<style lang="scss" scoped>
|
||||
@import '@/styles/mixins.scss';
|
||||
|
||||
.tx-item-wrap {
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
@@ -121,22 +123,33 @@ function onTouchEnd() {
|
||||
gap: 24rpx;
|
||||
border-radius: 20rpx;
|
||||
transition: transform 0.2s ease;
|
||||
background: #FFFFFF;
|
||||
background: $surface;
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
|
||||
&:active {
|
||||
background: #FFF0E6;
|
||||
background: $surface-warm;
|
||||
}
|
||||
}
|
||||
|
||||
// 左滑提示:首次加载时微微露出删除按钮
|
||||
.tx-item-wrap:first-child .tx-item:not(.no-hint) {
|
||||
animation: swipe-hint 0.6s ease 1.5s both;
|
||||
}
|
||||
|
||||
@keyframes swipe-hint {
|
||||
0% { transform: translateX(0); }
|
||||
40% { transform: translateX(-40rpx); }
|
||||
100% { transform: translateX(0); }
|
||||
}
|
||||
|
||||
.delete-btn {
|
||||
position: absolute;
|
||||
right: 0;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 160rpx;
|
||||
background: #FF6B6B;
|
||||
background: $danger;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
@@ -169,9 +182,9 @@ function onTouchEnd() {
|
||||
width: 28rpx;
|
||||
height: 28rpx;
|
||||
border-radius: 50%;
|
||||
background: #FFF0E6;
|
||||
background: $primary-light;
|
||||
font-size: 16rpx;
|
||||
color: #FF8C69;
|
||||
color: $primary;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
@@ -185,22 +198,20 @@ function onTouchEnd() {
|
||||
.name {
|
||||
font-size: 28rpx;
|
||||
font-weight: 500;
|
||||
color: #2D1B1B;
|
||||
color: $text;
|
||||
display: block;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
@include text-ellipsis;
|
||||
}
|
||||
|
||||
.meta {
|
||||
font-size: 24rpx;
|
||||
color: #8B7E7E;
|
||||
color: $text-sec;
|
||||
display: block;
|
||||
margin-top: 4rpx;
|
||||
}
|
||||
|
||||
.creator-name {
|
||||
color: #FF8C69;
|
||||
color: $primary;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
@@ -211,7 +222,13 @@ function onTouchEnd() {
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
|
||||
&.expense { color: #2D1B1B; }
|
||||
&.income { color: #7BC67E; }
|
||||
&.expense { color: $text; }
|
||||
&.income { color: $success; }
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.tx-item-wrap:first-child .tx-item {
|
||||
animation: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
Reference in New Issue
Block a user