feat: 交互优化与代码质量改进
- SvgIcon 改为 PNG 图标方案,组件重命名为 Icon - 修复 TransactionItem tap 事件名冲突,改为 item-tap - 修复 Stats 页面切换 tab 时 overview 不更新 - 修复 CategoryIcon bgColor 必填问题 - 修复 ChartWrapper import 顺序 - 修复 budget onDelete 空字符串问题 - 清理 profile 页面未使用 CSS - 统一所有页面 header 为 sticky 定位 - 修复导航栏返回按钮不统一 - 添加导出功能 loading 状态 - 优化 scroll-into-view 延迟
This commit is contained in:
@@ -7,13 +7,13 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = defineProps<{
|
||||
const props = withDefaults(defineProps<{
|
||||
label: string
|
||||
color: string
|
||||
bgColor: string
|
||||
bgColor?: string
|
||||
size?: 'sm' | 'md' | 'lg'
|
||||
selected?: boolean
|
||||
}>()
|
||||
}>(), { bgColor: '' })
|
||||
|
||||
const sizeMap = { sm: 64, md: 88, lg: 112 }
|
||||
const fontMap = { sm: 28, md: 36, lg: 48 }
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, watch, nextTick } from 'vue'
|
||||
import { ref, onMounted, watch, nextTick, getCurrentInstance } from 'vue'
|
||||
// @ts-ignore
|
||||
import uCharts from '@qiun/ucharts/u-charts.min.js'
|
||||
|
||||
@@ -60,8 +60,6 @@ function drawChart() {
|
||||
})
|
||||
}
|
||||
|
||||
import { getCurrentInstance } from 'vue'
|
||||
|
||||
function onTap(e: any) {
|
||||
chartInstance?.touchLegend(e)
|
||||
emit('tap', e)
|
||||
|
||||
90
client/src/components/Icon/Icon.vue
Normal file
90
client/src/components/Icon/Icon.vue
Normal file
@@ -0,0 +1,90 @@
|
||||
<template>
|
||||
<image v-if="iconSrc" class="icon" :src="iconSrc" :style="sizeStyle" mode="aspectFit" />
|
||||
<view v-else class="icon-fallback" :style="sizeStyle">?</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
name: string
|
||||
size?: number
|
||||
color?: string
|
||||
}>(), { size: 40, color: '#2D1B1B' })
|
||||
|
||||
// 图标路径映射表:name + color -> PNG 文件路径
|
||||
const iconMap: Record<string, Record<string, string>> = {
|
||||
arrowLeft: {
|
||||
'#2D1B1B': '/static/icons/arrow-left-dark.png',
|
||||
'#8B7E7E': '/static/icons/arrow-left-gray.png',
|
||||
},
|
||||
arrowRight: {
|
||||
'#8B7E7E': '/static/icons/arrow-right-gray.png',
|
||||
'#E0D6D0': '/static/icons/arrow-right-pale.png',
|
||||
},
|
||||
calendar: {
|
||||
'#8B7E7E': '/static/icons/calendar-gray.png',
|
||||
},
|
||||
edit: {
|
||||
'#8B7E7E': '/static/icons/edit-gray.png',
|
||||
'#BFB3B3': '/static/icons/edit-light.png',
|
||||
},
|
||||
'alert-circle': {
|
||||
'#BFB3B3': '/static/icons/alert-circle-light.png',
|
||||
},
|
||||
user: {
|
||||
'#8B7E7E': '/static/icons/user-gray.png',
|
||||
'#FFFFFF': '/static/icons/user-white.png',
|
||||
},
|
||||
bell: {
|
||||
'#8B7E7E': '/static/icons/bell-gray.png',
|
||||
},
|
||||
'trend-down': {
|
||||
'#FF6B6B': '/static/icons/trend-down-red.png',
|
||||
},
|
||||
'trend-up': {
|
||||
'#7BC67E': '/static/icons/trend-up-green.png',
|
||||
},
|
||||
chevronRight: {
|
||||
'#BFB3B3': '/static/icons/chevron-right-light.png',
|
||||
},
|
||||
check: {
|
||||
'#FFFFFF': '/static/icons/check-white.png',
|
||||
},
|
||||
trash: {
|
||||
'#FF8C69': '/static/icons/trash-orange.png',
|
||||
'#FF6B6B': '/static/icons/trash-red.png',
|
||||
'#FFFFFF': '/static/icons/trash-white.png',
|
||||
},
|
||||
}
|
||||
|
||||
const iconSrc = computed(() => {
|
||||
const variants = iconMap[props.name]
|
||||
if (!variants) {
|
||||
console.warn(`[Icon] 图标 "${props.name}" 不存在`)
|
||||
return ''
|
||||
}
|
||||
// 精确匹配颜色,找不到则使用第一个变体
|
||||
return variants[props.color] || Object.values(variants)[0] || ''
|
||||
})
|
||||
|
||||
const sizeStyle = computed(() => {
|
||||
const s = props.size + 'rpx'
|
||||
return { width: s, height: s }
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.icon {
|
||||
display: inline-block;
|
||||
}
|
||||
.icon-fallback {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: #F0E0D6;
|
||||
border-radius: 4rpx;
|
||||
color: #BFB3B3;
|
||||
font-size: 20rpx;
|
||||
}
|
||||
</style>
|
||||
@@ -4,16 +4,16 @@
|
||||
<text class="key-text">{{ key }}</text>
|
||||
</view>
|
||||
<view class="key fn" @tap="onDelete">
|
||||
<SvgIcon name="trash" :size="36" color="#FF8C69" />
|
||||
<Icon name="trash" :size="36" color="#FF8C69" />
|
||||
</view>
|
||||
<view class="key eq" @tap="onConfirm">
|
||||
<SvgIcon name="check" :size="36" color="#FFFFFF" />
|
||||
<Icon name="check" :size="36" color="#FFFFFF" />
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import SvgIcon from '@/components/SvgIcon/SvgIcon.vue'
|
||||
import Icon from '@/components/Icon/Icon.vue'
|
||||
|
||||
const keys = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '.', '0', '00']
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
<template>
|
||||
<view class="save-success" v-if="visible" @tap="hide">
|
||||
<view class="success-icon" :class="{ show: animating }">
|
||||
<SvgIcon name="check" :size="80" color="#FFFFFF" />
|
||||
<Icon name="check" :size="80" color="#FFFFFF" />
|
||||
</view>
|
||||
<text class="success-text" :class="{ show: animating }">{{ text }}</text>
|
||||
</view>
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, watch } from 'vue'
|
||||
import SvgIcon from '@/components/SvgIcon/SvgIcon.vue'
|
||||
import Icon from '@/components/Icon/Icon.vue'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
visible: boolean
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
<template>
|
||||
<!-- #ifdef H5 -->
|
||||
<image class="svg-icon" :src="svgData" :style="sizeStyle" mode="aspectFit" />
|
||||
<!-- #endif -->
|
||||
<!-- #ifdef MP-WEIXIN -->
|
||||
<view class="svg-icon-wrap" :style="sizeStyle">
|
||||
<rich-text :nodes="svgNode" :style="sizeStyle"></rich-text>
|
||||
</view>
|
||||
<!-- #endif -->
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
|
||||
const props = withDefaults(defineProps<{
|
||||
name: string
|
||||
size?: number
|
||||
color?: string
|
||||
}>(), { size: 40, color: '#2D1B1B' })
|
||||
|
||||
const icons: Record<string, string> = {
|
||||
close: '<path d="M18 6L6 18M6 6l12 12" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>',
|
||||
bell: '<path d="M18 8A6 6 0 006 8c0 7-3 9-3 9h18s-3-2-3-9M13.73 21a2 2 0 01-3.46 0" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>',
|
||||
user: '<path d="M20 21v-2a4 4 0 00-4-4H8a4 4 0 00-4 4v2M12 11a4 4 0 100-8 4 4 0 000 8z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>',
|
||||
'trend-down': '<path d="M23 18l-9.5-9.5-5 5L1 6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/><path d="M17 18h6v-6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>',
|
||||
'trend-up': '<path d="M23 6l-9.5 9.5-5-5L1 18" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/><path d="M17 6h6v6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>',
|
||||
calendar: '<rect x="3" y="4" width="18" height="18" rx="2" ry="2" stroke="currentColor" stroke-width="2"/><path d="M16 2v4M8 2v4M3 10h18" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>',
|
||||
edit: '<path d="M11 4H4a2 2 0 00-2 2v14a2 2 0 002 2h14a2 2 0 002-2v-7" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/><path d="M18.5 2.5a2.121 2.121 0 013 3L12 15l-4 1 1-4 9.5-9.5z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>',
|
||||
budget: '<path d="M12 2v20M17 5H9.5a3.5 3.5 0 000 7h5a3.5 3.5 0 010 7H6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>',
|
||||
category: '<rect x="3" y="3" width="7" height="7" rx="1" stroke="currentColor" stroke-width="2"/><rect x="14" y="3" width="7" height="7" rx="1" stroke="currentColor" stroke-width="2"/><rect x="3" y="14" width="7" height="7" rx="1" stroke="currentColor" stroke-width="2"/><rect x="14" y="14" width="7" height="7" rx="1" stroke="currentColor" stroke-width="2"/>',
|
||||
export: '<path d="M21 15v4a2 2 0 01-2 2H5a2 2 0 01-2-2v-4M7 10l5 5 5-5M12 15V3" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>',
|
||||
moon: '<path d="M21 12.79A9 9 0 1111.21 3 7 7 0 0021 12.79z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>',
|
||||
'alert-circle': '<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2"/><path d="M12 8v4M12 16h.01" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>',
|
||||
info: '<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2"/><path d="M12 16v-4M12 8h.01" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>',
|
||||
chevronRight: '<path d="M9 18l6-6-6-6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>',
|
||||
chevronLeft: '<path d="M15 18l-6-6 6-6" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>',
|
||||
arrowLeft: '<path d="M19 12H5M12 19l-7-7 7-7" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>',
|
||||
arrowRight: '<path d="M5 12h14M12 5l7 7-7 7" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>',
|
||||
trash: '<path d="M3 6h18M19 6v14a2 2 0 01-2 2H7a2 2 0 01-2-2V6m3 0V4a2 2 0 012-2h4a2 2 0 012 2v2" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>',
|
||||
check: '<path d="M20 6L9 17l-5-5" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>',
|
||||
}
|
||||
|
||||
const svgData = computed(() => {
|
||||
const path = icons[props.name] || ''
|
||||
const svg = `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="${props.color}" stroke-linecap="round" stroke-linejoin="round">${path}</svg>`
|
||||
return 'data:image/svg+xml;base64,' + btoa(svg)
|
||||
})
|
||||
|
||||
const svgNode = computed(() => {
|
||||
const path = icons[props.name] || ''
|
||||
return `<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="${props.color}" stroke-linecap="round" stroke-linejoin="round">${path}</svg>`
|
||||
})
|
||||
|
||||
const sizeStyle = computed(() => {
|
||||
const s = props.size + 'rpx'
|
||||
return { width: s, height: s }
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.svg-icon {
|
||||
display: inline-block;
|
||||
}
|
||||
.svg-icon-wrap {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
overflow: hidden;
|
||||
}
|
||||
</style>
|
||||
@@ -3,11 +3,10 @@
|
||||
<view
|
||||
class="tx-item"
|
||||
:style="{ transform: `translateX(${offsetX}rpx)` }"
|
||||
@tap="$emit('tap', item)"
|
||||
@longpress="$emit('longpress', item)"
|
||||
@touchstart="onTouchStart"
|
||||
@touchmove="onTouchMove"
|
||||
@touchend="onTouchEnd"
|
||||
@tap="$emit('item-tap', item)"
|
||||
@touchstart="!disableSwipe && onTouchStart($event)"
|
||||
@touchmove="!disableSwipe && onTouchMove($event)"
|
||||
@touchend="!disableSwipe && onTouchEnd()"
|
||||
>
|
||||
<view class="icon-wrap" :style="{ background: item.category_color + '20' }">
|
||||
<text class="icon-text" :style="{ color: item.category_color }">{{ item.category_name?.[0] || '?' }}</text>
|
||||
@@ -19,7 +18,7 @@
|
||||
<text class="amount" :class="item.type">{{ item.type === 'expense' ? '-' : '+' }}{{ formatAmount(item.amount) }}</text>
|
||||
</view>
|
||||
<view class="delete-btn" :class="{ visible: showDelete }" @tap="$emit('delete', item)">
|
||||
<SvgIcon name="trash" :size="32" color="#FFFFFF" />
|
||||
<Icon name="trash" :size="32" color="#FFFFFF" />
|
||||
</view>
|
||||
</view>
|
||||
</template>
|
||||
@@ -27,7 +26,7 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { formatAmount, formatDate } from '@/utils/format'
|
||||
import SvgIcon from '@/components/SvgIcon/SvgIcon.vue'
|
||||
import Icon from '@/components/Icon/Icon.vue'
|
||||
|
||||
withDefaults(defineProps<{
|
||||
item: {
|
||||
@@ -41,9 +40,11 @@ withDefaults(defineProps<{
|
||||
category_color?: string
|
||||
}
|
||||
hideDate?: boolean
|
||||
}>(), { hideDate: false })
|
||||
/** 禁用滑动删除(首页预览等场景) */
|
||||
disableSwipe?: boolean
|
||||
}>(), { hideDate: false, disableSwipe: false })
|
||||
|
||||
defineEmits(['tap', 'longpress', 'delete'])
|
||||
defineEmits(['item-tap', 'delete'])
|
||||
|
||||
const startX = ref(0)
|
||||
const offsetX = ref(0)
|
||||
|
||||
Reference in New Issue
Block a user