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:
2026-06-02 16:04:17 +08:00
parent 36baa9a2b5
commit d5e5655b88
34 changed files with 318 additions and 451 deletions

View File

@@ -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 }

View File

@@ -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)

View 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>

View File

@@ -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']

View File

@@ -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

View File

@@ -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>

View File

@@ -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)

View File

@@ -1,12 +1,14 @@
<template>
<view class="page">
<view class="status-bar" :style="{ height: statusBarHeight + 'px' }"></view>
<view class="header">
<view class="close" @tap="goBack">
<SvgIcon name="close" :size="36" color="#8B7E7E" />
<view class="header-fixed">
<view class="status-bar" :style="{ height: statusBarHeight + 'px' }"></view>
<view class="header">
<view class="nav-back" @tap="goBack">
<Icon name="arrowLeft" :size="36" color="#2D1B1B" />
</view>
<text class="title">{{ editId ? '编辑记录' : '记一笔' }}</text>
<view style="width: 88rpx;"></view>
</view>
<text class="title">{{ editId ? '编辑记录' : '记一笔' }}</text>
<view style="width: 64rpx;"></view>
</view>
<view class="type-toggle-wrap">
@@ -34,12 +36,12 @@
<view class="extra-fields">
<picker mode="date" :value="selectedDate" :end="todayStr" @change="onDateChange">
<view class="extra-row">
<SvgIcon name="calendar" :size="28" color="#8B7E7E" />
<Icon name="calendar" :size="28" color="#8B7E7E" />
<text class="extra-text">{{ dateLabel }}</text>
</view>
</picker>
<view class="extra-row">
<SvgIcon name="edit" :size="28" color="#8B7E7E" />
<Icon name="edit" :size="28" color="#8B7E7E" />
<input class="extra-input" v-model="note" placeholder="添加备注..." placeholder-class="placeholder" maxlength="50" />
</view>
</view>
@@ -51,12 +53,12 @@
</template>
<script setup lang="ts">
import { ref, computed, watch, onMounted } from 'vue'
import { ref, computed, watch, onMounted, nextTick } from 'vue'
import { useCategoryStore } from '@/stores/category'
import { useTransactionStore } from '@/stores/transaction'
import Numpad from '@/components/Numpad/Numpad.vue'
import CategoryIcon from '@/components/CategoryIcon/CategoryIcon.vue'
import SvgIcon from '@/components/SvgIcon/SvgIcon.vue'
import Icon from '@/components/Icon/Icon.vue'
import SaveSuccess from '@/components/SaveSuccess/SaveSuccess.vue'
import { statusBarHeight } from '@/utils/system'
@@ -131,8 +133,10 @@ onMounted(async () => {
lastCatByType[existing.type] = existing.category_id
note.value = existing.note || ''
selectedDate.value = existing.date.slice(0, 10)
// Scroll to selected category
setTimeout(() => { scrollToCat.value = 'cat-' + existing.category_id }, 100)
// 等待分类列表渲染完成后滚动到选中项
nextTick(() => {
setTimeout(() => { scrollToCat.value = 'cat-' + existing.category_id }, 200)
})
} else {
uni.showToast({ title: '记录不存在', icon: 'none' })
setTimeout(() => uni.navigateBack(), 1500)
@@ -233,6 +237,13 @@ function goBack() {
flex-direction: column;
}
.header-fixed {
position: sticky;
top: 0;
z-index: 100;
background: #FFF8F0;
}
.status-bar {
background: #FFF8F0;
}
@@ -244,12 +255,15 @@ function goBack() {
padding: 16rpx 40rpx;
}
.close {
.nav-back {
width: 88rpx;
height: 88rpx;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
&:active { background: #F0E0D6; }
}
.title { font-size: 32rpx; font-weight: 500; color: #2D1B1B; }
@@ -317,13 +331,12 @@ function goBack() {
.category-scroll {
max-height: 320rpx;
padding: 0 40rpx;
}
.category-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 20rpx;
gap: 16rpx;
padding: 8rpx 0;
}

View File

@@ -1,7 +1,9 @@
<template>
<view class="page">
<view class="status-bar" :style="{ height: statusBarHeight + 'px' }"></view>
<text class="page-title">全部账单</text>
<view class="header-fixed">
<view class="status-bar" :style="{ height: statusBarHeight + 'px' }"></view>
<text class="page-title">全部账单</text>
</view>
<view class="tabs-wrap">
<view class="tabs">
@@ -25,7 +27,7 @@
:key="item.id"
:item="item"
:hideDate="true"
@tap="onEdit(item)"
@item-tap="onEdit(item)"
@delete="onDelete(item)"
/>
</view>
@@ -57,7 +59,7 @@
</view>
<view class="state-box" v-if="loadError">
<SvgIcon name="alert-circle" :size="48" color="#BFB3B3" />
<Icon name="alert-circle" :size="48" color="#BFB3B3" />
<text class="state-text">加载失败下拉刷新重试</text>
</view>
@@ -72,7 +74,7 @@ import { ref, computed, onMounted } from 'vue'
import { onReachBottom, onPullDownRefresh } from '@dcloudio/uni-app'
import { useTransactionStore } from '@/stores/transaction'
import TransactionItem from '@/components/TransactionItem/TransactionItem.vue'
import SvgIcon from '@/components/SvgIcon/SvgIcon.vue'
import Icon from '@/components/Icon/Icon.vue'
import Skeleton from '@/components/Skeleton/Skeleton.vue'
import { statusBarHeight } from '@/utils/system'
import { formatAmount, formatDate } from '@/utils/format'
@@ -180,6 +182,13 @@ onPullDownRefresh(() => {
padding: 0 0 180rpx;
}
.header-fixed {
position: sticky;
top: 0;
z-index: 100;
background: #FFF8F0;
}
.status-bar {
background: #FFF8F0;
}

View File

@@ -1,12 +1,14 @@
<template>
<view class="page">
<view class="status-bar" :style="{ height: statusBarHeight + 'px' }"></view>
<view class="nav-bar">
<view class="nav-back" @tap="goBack">
<SvgIcon name="chevronLeft" :size="40" color="#2D1B1B" />
<view class="header-fixed">
<view class="status-bar" :style="{ height: statusBarHeight + 'px' }"></view>
<view class="nav-bar">
<view class="nav-back" @tap="goBack">
<Icon name="arrowLeft" :size="36" color="#2D1B1B" />
</view>
<text class="nav-title">预算设置</text>
<view class="nav-placeholder"></view>
</view>
<text class="nav-title">预算设置</text>
<view class="nav-placeholder"></view>
</view>
<view class="content">
@@ -64,14 +66,14 @@
</template>
<script setup lang="ts">
import { ref, computed, onMounted } from 'vue'
import { ref, onMounted } from 'vue'
import { useBudgetStore } from '@/stores/budget'
import { useStatsStore } from '@/stores/stats'
import { formatAmount, getCurrentMonth } from '@/utils/format'
import { statusBarHeight } from '@/utils/system'
import BudgetBar from '@/components/BudgetBar/BudgetBar.vue'
import Numpad from '@/components/Numpad/Numpad.vue'
import SvgIcon from '@/components/SvgIcon/SvgIcon.vue'
import Icon from '@/components/Icon/Icon.vue'
const budgetStore = useBudgetStore()
const statsStore = useStatsStore()
@@ -130,7 +132,11 @@ function onInput(key: string) {
}
function onDelete() {
amountStr.value = amountStr.value.slice(0, -1)
if (amountStr.value.length > 1) {
amountStr.value = amountStr.value.slice(0, -1)
} else {
amountStr.value = '0'
}
}
async function onConfirm() {
@@ -164,6 +170,13 @@ function goBack() {
padding-bottom: 120rpx;
}
.header-fixed {
position: sticky;
top: 0;
z-index: 100;
background: #FFF8F0;
}
.status-bar {
background: #FFF8F0;
}
@@ -175,11 +188,14 @@ function goBack() {
}
.nav-back {
width: 72rpx;
height: 72rpx;
width: 88rpx;
height: 88rpx;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
&:active { background: #F0E0D6; }
}
.nav-title {

View File

@@ -1,12 +1,14 @@
<template>
<view class="page">
<view class="status-bar" :style="{ height: statusBarHeight + 'px' }"></view>
<view class="header">
<view class="back" @tap="uni.navigateBack()">
<SvgIcon name="arrowLeft" :size="36" color="#2D1B1B" />
<view class="header-fixed">
<view class="status-bar" :style="{ height: statusBarHeight + 'px' }"></view>
<view class="header">
<view class="nav-back" @tap="goBack">
<Icon name="arrowLeft" :size="36" color="#2D1B1B" />
</view>
<text class="title">分类管理</text>
<view style="width: 88rpx;"></view>
</view>
<text class="title">分类管理</text>
<view style="width: 88rpx;"></view>
</view>
<view class="tabs-wrap">
@@ -24,7 +26,7 @@
<text class="cat-badge" v-if="cat.is_custom">自定义</text>
</view>
<view class="cat-actions" v-if="cat.is_custom" @tap="onDelete(cat)">
<SvgIcon name="trash" :size="28" color="#FF6B6B" />
<Icon name="trash" :size="28" color="#FF6B6B" />
</view>
</view>
</view>
@@ -52,7 +54,7 @@
import { ref, computed, onMounted } from 'vue'
import { useCategoryStore } from '@/stores/category'
import CategoryIcon from '@/components/CategoryIcon/CategoryIcon.vue'
import SvgIcon from '@/components/SvgIcon/SvgIcon.vue'
import Icon from '@/components/Icon/Icon.vue'
import { statusBarHeight } from '@/utils/system'
const catStore = useCategoryStore()
@@ -65,6 +67,10 @@ const currentCategories = computed(() => catStore.getByType(tabType.value))
onMounted(() => catStore.fetchCategories())
function goBack() {
uni.navigateBack()
}
async function onAdd() {
const name = newName.value.trim()
if (!name) return
@@ -132,6 +138,13 @@ async function onDelete(cat: any) {
padding: 0 0 180rpx;
}
.header-fixed {
position: sticky;
top: 0;
z-index: 100;
background: #FFF8F0;
}
.status-bar { background: #FFF8F0; }
.header {
@@ -141,12 +154,15 @@ async function onDelete(cat: any) {
padding: 16rpx 40rpx;
}
.back {
.nav-back {
width: 88rpx;
height: 88rpx;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
&:active { background: #F0E0D6; }
}
.title { font-size: 32rpx; font-weight: 600; color: #2D1B1B; }

View File

@@ -1,13 +1,15 @@
<template>
<view class="page">
<view class="status-bar" :style="{ height: statusBarHeight + 'px' }"></view>
<view class="header">
<view class="avatar-wrap">
<SvgIcon name="user" :size="36" color="#8B7E7E" />
</view>
<text class="greeting">{{ greeting }}小菜</text>
<view class="bell" @tap="uni.showToast({ title: '暂无通知', icon: 'none' })">
<SvgIcon name="bell" :size="40" color="#8B7E7E" />
<view class="header-fixed">
<view class="status-bar" :style="{ height: statusBarHeight + 'px' }"></view>
<view class="header">
<view class="avatar-wrap">
<Icon name="user" :size="36" color="#8B7E7E" />
</view>
<text class="greeting">{{ greeting }}小菜</text>
<view class="bell" @tap="showNotification">
<Icon name="bell" :size="40" color="#8B7E7E" />
</view>
</view>
</view>
@@ -68,11 +70,11 @@
<view class="quick-actions">
<view class="quick-btn" @tap="goAdd('expense')">
<SvgIcon name="trend-down" :size="48" color="#FF6B6B" />
<Icon name="trend-down" :size="48" color="#FF6B6B" />
<text class="quick-text">记支出</text>
</view>
<view class="quick-btn" @tap="goAdd('income')">
<SvgIcon name="trend-up" :size="48" color="#7BC67E" />
<Icon name="trend-up" :size="48" color="#7BC67E" />
<text class="quick-text">记收入</text>
</view>
</view>
@@ -83,7 +85,7 @@
</view>
<view class="tx-list" v-if="!loading && !loadError && recentTx.length > 0">
<TransactionItem v-for="item in recentTx" :key="item.id" :item="item" @tap="onTxTap(item)" />
<TransactionItem v-for="item in recentTx" :key="item.id" :item="item" :disableSwipe="true" @item-tap="onTxTap(item)" />
</view>
<view class="tx-list" v-if="loading">
@@ -98,12 +100,12 @@
</view>
<view class="state-box" v-if="loadError">
<SvgIcon name="alert-circle" :size="48" color="#BFB3B3" />
<Icon name="alert-circle" :size="48" color="#BFB3B3" />
<text class="state-text">加载失败下拉刷新重试</text>
</view>
<view class="state-box" v-if="!loading && !loadError && recentTx.length === 0">
<SvgIcon name="edit" :size="48" color="#BFB3B3" />
<Icon name="edit" :size="48" color="#BFB3B3" />
<text class="state-text">还没有记录快去记一笔吧</text>
</view>
</view>
@@ -119,7 +121,7 @@ import { formatAmount, formatAmountRaw } from '@/utils/format'
import { statusBarHeight } from '@/utils/system'
import BudgetBar from '@/components/BudgetBar/BudgetBar.vue'
import TransactionItem from '@/components/TransactionItem/TransactionItem.vue'
import SvgIcon from '@/components/SvgIcon/SvgIcon.vue'
import Icon from '@/components/Icon/Icon.vue'
import Skeleton from '@/components/Skeleton/Skeleton.vue'
const txStore = useTransactionStore()
@@ -201,6 +203,10 @@ function goAdd(type: string) {
uni.navigateTo({ url: `/pages/add/index?type=${type}` })
}
function showNotification() {
uni.showToast({ title: '暂无通知', icon: 'none' })
}
function onTxTap(item: any) {
uni.navigateTo({ url: `/pages/add/index?editId=${item.id}` })
}
@@ -217,6 +223,13 @@ function goBills() {
padding: 0 0 180rpx;
}
.header-fixed {
position: sticky;
top: 0;
z-index: 100;
background: #FFF8F0;
}
.status-bar {
background: #FFF8F0;
}
@@ -301,7 +314,8 @@ function goBills() {
.mini-stat {
flex: 1;
padding: 24rpx;
min-width: 0;
padding: 24rpx 12rpx;
border-radius: 24rpx;
background: #FFF8F0;
text-align: center;
@@ -316,8 +330,11 @@ function goBills() {
.mini-value {
font-family: 'Fredoka', sans-serif;
font-size: 36rpx;
font-size: 32rpx;
font-weight: 600;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
&.income { color: #7BC67E; }
&.expense { color: #FF6B6B; }
@@ -413,6 +430,9 @@ function goBills() {
.section-more {
font-size: 24rpx;
color: #FF8C69;
padding: 8rpx 0;
&:active { opacity: 0.6; }
}
.tx-list {

View File

@@ -1,11 +1,13 @@
<template>
<view class="page">
<view class="status-bar" :style="{ height: statusBarHeight + 'px' }"></view>
<text class="page-title">我的</text>
<view class="header-fixed">
<view class="status-bar" :style="{ height: statusBarHeight + 'px' }"></view>
<text class="page-title">我的</text>
</view>
<view class="profile-card">
<view class="p-avatar">
<SvgIcon name="user" :size="64" color="#FFFFFF" />
<Icon name="user" :size="64" color="#FFFFFF" />
</view>
<view class="p-info">
<text class="p-name">小菜</text>
@@ -31,22 +33,22 @@
<view class="menu-item" @tap="goBudget">
<text class="mi-label">预算设置</text>
<text class="mi-extra" v-if="budget">{{ formatAmount(budget.amount) }}</text>
<SvgIcon name="chevronRight" :size="24" color="#BFB3B3" />
<Icon name="chevronRight" :size="24" color="#BFB3B3" />
</view>
<view class="menu-item" @tap="goCategoryManage">
<text class="mi-label">分类管理</text>
<SvgIcon name="chevronRight" :size="24" color="#BFB3B3" />
<Icon name="chevronRight" :size="24" color="#BFB3B3" />
</view>
<view class="menu-item" @tap="exportData">
<text class="mi-label">数据导出</text>
<SvgIcon name="chevronRight" :size="24" color="#BFB3B3" />
<Icon name="chevronRight" :size="24" color="#BFB3B3" />
</view>
</view>
<view class="menu-card mt">
<view class="menu-item" @tap="showAbout = true">
<text class="mi-label">关于小菜</text>
<SvgIcon name="chevronRight" :size="24" color="#BFB3B3" />
<Icon name="chevronRight" :size="24" color="#BFB3B3" />
</view>
</view>
@@ -55,7 +57,7 @@
<view class="modal" @tap.stop>
<text class="about-title">小菜记账</text>
<text class="about-version">v1.0.0</text>
<text class="about-desc">可爱 x 高级的个人记账小程序</text>
<text class="about-desc">温暖 x 轻松的个人记账小程序</text>
<text class="about-desc">让记账从负担变成享受</text>
<view class="modal-btns" style="margin-top: 40rpx;">
<view class="modal-btn confirm" @tap="showAbout = false">知道了</view>
@@ -73,7 +75,7 @@ import { useBudgetStore } from '@/stores/budget'
import { formatAmount } from '@/utils/format'
import { statusBarHeight } from '@/utils/system'
import { request } from '@/utils/request'
import SvgIcon from '@/components/SvgIcon/SvgIcon.vue'
import Icon from '@/components/Icon/Icon.vue'
const txStore = useTransactionStore()
const statsStore = useStatsStore()
@@ -115,6 +117,7 @@ function getLocalDateStr(): string {
}
async function exportData() {
uni.showLoading({ title: '导出中...' })
try {
const data = await request<any>({ url: '/transactions', data: { page: 1, pageSize: 9999 } })
const list = data.list
@@ -149,6 +152,7 @@ async function exportData() {
a.download = `小菜记账_${getLocalDateStr()}.csv`
a.click()
URL.revokeObjectURL(url)
uni.hideLoading()
uni.showToast({ title: '导出成功', icon: 'success' })
// #endif
@@ -156,6 +160,7 @@ async function exportData() {
const fs = uni.getFileSystemManager()
const filePath = `${wx.env.USER_DATA_PATH}/小菜记账_${getLocalDateStr()}.csv`
fs.writeFileSync(filePath, csv, 'utf-8')
uni.hideLoading()
uni.openDocument({
filePath,
fileType: 'csv',
@@ -164,6 +169,7 @@ async function exportData() {
})
// #endif
} catch {
uni.hideLoading()
uni.showToast({ title: '导出失败', icon: 'none' })
}
}
@@ -176,6 +182,13 @@ async function exportData() {
padding: 0 0 180rpx;
}
.header-fixed {
position: sticky;
top: 0;
z-index: 100;
background: #FFF8F0;
}
.status-bar {
background: #FFF8F0;
}
@@ -346,24 +359,6 @@ async function exportData() {
padding: 48rpx;
}
.modal-title {
font-size: 32rpx;
font-weight: 600;
color: #2D1B1B;
display: block;
text-align: center;
margin-bottom: 32rpx;
}
.modal-input {
height: 96rpx;
background: #FFF8F0;
border-radius: 24rpx;
padding: 0 32rpx;
font-size: 28rpx;
margin-bottom: 32rpx;
}
.modal-btns {
display: flex;
gap: 24rpx;
@@ -379,11 +374,6 @@ async function exportData() {
font-size: 28rpx;
font-weight: 600;
&.cancel {
background: #FFF8F0;
color: #8B7E7E;
}
&.confirm {
background: linear-gradient(135deg, #FF8C69, #E67355);
color: #FFFFFF;

View File

@@ -1,15 +1,17 @@
<template>
<view class="page">
<view class="status-bar" :style="{ height: statusBarHeight + 'px' }"></view>
<text class="page-title">统计分析</text>
<view class="header-fixed">
<view class="status-bar" :style="{ height: statusBarHeight + 'px' }"></view>
<text class="page-title">统计分析</text>
</view>
<view class="month-picker">
<view class="arrow" @tap="prevMonth">
<SvgIcon name="arrowLeft" :size="32" color="#8B7E7E" />
<Icon name="arrowLeft" :size="32" color="#8B7E7E" />
</view>
<text class="month-text">{{ formatMonth(currentMonth) }}</text>
<view class="arrow" :class="{ disabled: isCurrentMonth }" @tap="nextMonth">
<SvgIcon name="arrowRight" :size="32" :color="isCurrentMonth ? '#E0D6D0' : '#8B7E7E'" />
<Icon name="arrowRight" :size="32" :color="isCurrentMonth ? '#E0D6D0' : '#8B7E7E'" />
</view>
</view>
@@ -74,7 +76,7 @@
</view>
<view class="state-box" v-if="loadError">
<SvgIcon name="alert-circle" :size="48" color="#BFB3B3" />
<Icon name="alert-circle" :size="48" color="#BFB3B3" />
<text class="state-text">加载失败请稍后重试</text>
</view>
@@ -121,7 +123,7 @@ import { onPullDownRefresh } from '@dcloudio/uni-app'
import { useStatsStore } from '@/stores/stats'
import { formatAmount, formatMonth, getCurrentMonth } from '@/utils/format'
import { statusBarHeight } from '@/utils/system'
import SvgIcon from '@/components/SvgIcon/SvgIcon.vue'
import Icon from '@/components/Icon/Icon.vue'
import Skeleton from '@/components/Skeleton/Skeleton.vue'
import ChartWrapper from '@/components/ChartWrapper/ChartWrapper.vue'
@@ -235,15 +237,17 @@ async function loadData() {
}
}
// Only re-fetch category/trend data (not overview) when tab switches
// Re-fetch all data when tab switches
async function loadTabData() {
try {
await Promise.all([
statsStore.fetchOverview(currentMonth.value),
statsStore.fetchCategoryStats(currentMonth.value, tabType.value),
statsStore.fetchTrend(currentMonth.value, tabType.value),
fetchMaxSingle(),
fetchPrevMonthData()
])
overview.value = statsStore.overview
categoryStats.value = statsStore.categoryStats
trendData.value = statsStore.trendData
} catch (e) {
@@ -311,6 +315,13 @@ function getBarWidth(amount: number) {
padding: 0 0 180rpx;
}
.header-fixed {
position: sticky;
top: 0;
z-index: 100;
background: #FFF8F0;
}
.status-bar {
background: #FFF8F0;
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 836 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 367 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 380 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 375 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 377 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 713 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 378 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 372 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 345 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 719 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 705 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 369 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 354 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 322 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 504 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 506 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 557 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 480 B

View File

@@ -1,9 +1,5 @@
// API 基础地址 - 上线后改成真实域名
let BASE_URL = 'http://106.14.208.43:3000/api'
if (typeof wx !== 'undefined' && wx.getSystemInfoSync) {
// 微信小程序环境使用 HTTPS 域名(上线后配置)
// BASE_URL = 'https://your-domain.com/api'
}
// API 基础地址
const BASE_URL = 'https://xiaocai.j35.site/api'
interface RequestOptions {
url: string
@@ -48,10 +44,12 @@ export function request<T = any>(options: RequestOptions): Promise<T> {
reject({ code: 40100, message: '未登录' })
}).finally(() => { isRedirectingToLogin = false })
} else {
// 小程序环境重新触发 wx.login
uni.showToast({ title: '请重新登录', icon: 'none' })
setTimeout(() => { reLogin(); isRedirectingToLogin = false }, 1500)
reject({ code: 40100, message: '未登录' })
// 小程序环境自动重新登录
reLogin().then(() => {
request(options).then(resolve).catch(reject)
}).catch(() => {
reject({ code: 40100, message: '未登录' })
}).finally(() => { isRedirectingToLogin = false })
}
} else {
reject({ code: 40100, message: '未登录' })
@@ -76,25 +74,38 @@ export function request<T = any>(options: RequestOptions): Promise<T> {
}
// 微信小程序自动重新登录
function reLogin() {
// #ifdef MP-WEIXIN
uni.login({
provider: 'weixin',
success: async (loginRes) => {
if (loginRes.code) {
try {
const data = await request<{ token: string; userId: number }>({
url: '/auth/login',
method: 'POST',
data: { code: loginRes.code }
})
uni.setStorageSync('xc:token', data.token)
uni.setStorageSync('xc:userId', data.userId)
} catch (e) {
console.error('[Auth] Re-login failed:', e)
function reLogin(): Promise<void> {
return new Promise((resolve, reject) => {
// #ifdef MP-WEIXIN
uni.login({
provider: 'weixin',
success: async (loginRes) => {
if (loginRes.code) {
try {
const data = await request<{ token: string; userId: number }>({
url: '/auth/login',
method: 'POST',
data: { code: loginRes.code }
})
uni.setStorageSync('xc:token', data.token)
uni.setStorageSync('xc:userId', data.userId)
resolve()
} catch (e) {
console.error('[Auth] Re-login failed:', e)
reject(e)
}
} else {
reject(new Error('wx.login failed'))
}
},
fail: (err) => {
console.error('[Auth] wx.login failed:', err)
reject(err)
}
}
})
// #endif
// #ifndef MP-WEIXIN
resolve()
// #endif
})
// #endif
}

View File

@@ -1,238 +0,0 @@
/**
* 生成 Tab Bar 图标 PNG 文件 (81x81)
* 使用 Node.js 内置模块,无需安装额外依赖
*/
const fs = require('fs')
const path = require('path')
const zlib = require('zlib')
const SIZE = 81
const OUT_DIR = path.join(__dirname, '../client/src/static/icons')
// 颜色
const INACTIVE = [191, 179, 179] // #BFB3B3
const ACTIVE = [255, 140, 105] // #FF8C69
const TRANSPARENT = [0, 0, 0, 0]
function createPNG(pixels) {
// PNG signature
const signature = Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])
// IHDR chunk
const ihdr = Buffer.alloc(13)
ihdr.writeUInt32BE(SIZE, 0)
ihdr.writeUInt32BE(SIZE, 4)
ihdr[8] = 8 // bit depth
ihdr[9] = 6 // color type: RGBA
ihdr[10] = 0 // compression
ihdr[11] = 0 // filter
ihdr[12] = 0 // interlace
// Raw image data: filter byte (0) + RGBA pixels per row
const rawData = Buffer.alloc(SIZE * (1 + SIZE * 4))
for (let y = 0; y < SIZE; y++) {
const rowOffset = y * (1 + SIZE * 4)
rawData[rowOffset] = 0 // no filter
for (let x = 0; x < SIZE; x++) {
const srcIdx = (y * SIZE + x) * 4
const dstIdx = rowOffset + 1 + x * 4
rawData[dstIdx] = pixels[srcIdx]
rawData[dstIdx + 1] = pixels[srcIdx + 1]
rawData[dstIdx + 2] = pixels[srcIdx + 2]
rawData[dstIdx + 3] = pixels[srcIdx + 3]
}
}
const compressed = zlib.deflateSync(rawData)
function makeChunk(type, data) {
const typeBuffer = Buffer.from(type, 'ascii')
const length = Buffer.alloc(4)
length.writeUInt32BE(data.length, 0)
const combined = Buffer.concat([typeBuffer, data])
const crc = crc32(combined)
return Buffer.concat([length, combined, crc])
}
const ihdrChunk = makeChunk('IHDR', ihdr)
const idatChunk = makeChunk('IDAT', compressed)
const iendChunk = makeChunk('IEND', Buffer.alloc(0))
return Buffer.concat([signature, ihdrChunk, idatChunk, iendChunk])
}
// CRC32 table
const crcTable = new Uint32Array(256)
for (let n = 0; n < 256; n++) {
let c = n
for (let k = 0; k < 8; k++) {
c = (c & 1) ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1)
}
crcTable[n] = c
}
function crc32(buf) {
let crc = 0xFFFFFFFF
for (let i = 0; i < buf.length; i++) {
crc = crcTable[(crc ^ buf[i]) & 0xFF] ^ (crc >>> 8)
}
const result = Buffer.alloc(4)
result.writeUInt32BE((crc ^ 0xFFFFFFFF) >>> 0, 0)
return result
}
function makePixels(drawFn, color) {
const pixels = new Uint8Array(SIZE * SIZE * 4)
// Fill transparent
for (let i = 0; i < pixels.length; i += 4) {
pixels[i + 3] = 0
}
drawFn(pixels, color)
return pixels
}
function setPixel(pixels, x, y, r, g, b, a = 255) {
if (x < 0 || x >= SIZE || y < 0 || y >= SIZE) return
const idx = (y * SIZE + x) * 4
// Alpha blending
const srcA = a / 255
const dstA = pixels[idx + 3] / 255
const outA = srcA + dstA * (1 - srcA)
if (outA > 0) {
pixels[idx] = Math.round((r * srcA + pixels[idx] * dstA * (1 - srcA)) / outA)
pixels[idx + 1] = Math.round((g * srcA + pixels[idx + 1] * dstA * (1 - srcA)) / outA)
pixels[idx + 2] = Math.round((b * srcA + pixels[idx + 2] * dstA * (1 - srcA)) / outA)
pixels[idx + 3] = Math.round(outA * 255)
}
}
function drawLine(pixels, x0, y0, x1, y1, r, g, b, thickness = 2) {
const dx = Math.abs(x1 - x0)
const dy = Math.abs(y1 - y0)
const sx = x0 < x1 ? 1 : -1
const sy = y0 < y1 ? 1 : -1
let err = dx - dy
while (true) {
for (let tx = -thickness; tx <= thickness; tx++) {
for (let ty = -thickness; ty <= thickness; ty++) {
if (tx * tx + ty * ty <= thickness * thickness) {
setPixel(pixels, x0 + tx, y0 + ty, r, g, b)
}
}
}
if (x0 === x1 && y0 === y1) break
const e2 = 2 * err
if (e2 > -dy) { err -= dy; x0 += sx }
if (e2 < dx) { err += dx; y0 += sy }
}
}
function drawCircle(pixels, cx, cy, radius, r, g, b, filled = true) {
for (let y = -radius; y <= radius; y++) {
for (let x = -radius; x <= radius; x++) {
const dist = Math.sqrt(x * x + y * y)
if (filled ? dist <= radius : (dist >= radius - 1.5 && dist <= radius + 0.5)) {
setPixel(pixels, cx + x, cy + y, r, g, b)
}
}
}
}
function drawRect(pixels, x, y, w, h, r, g, b) {
for (let dy = 0; dy < h; dy++) {
for (let dx = 0; dx < w; dx++) {
setPixel(pixels, x + dx, y + dy, r, g, b)
}
}
}
// === Icon drawing functions ===
function drawHome(pixels, color) {
const [r, g, b] = color
const cx = 40, cy = 40
// House body (rectangle)
drawRect(pixels, 24, 38, 34, 26, r, g, b)
// Roof (triangle)
for (let i = 0; i < 20; i++) {
drawLine(pixels, cx, 18, cx - 20 + i * 2, 38, r, g, b, 1)
}
// Door
drawRect(pixels, 35, 50, 12, 14, 255, 248, 240)
}
function drawChart(pixels, color) {
const [r, g, b] = color
// Three bars
drawRect(pixels, 18, 45, 12, 20, r, g, b)
drawRect(pixels, 35, 30, 12, 35, r, g, b)
drawRect(pixels, 52, 20, 12, 45, r, g, b)
// Base line
drawRect(pixels, 14, 65, 54, 2, r, g, b)
}
function drawBill(pixels, color) {
const [r, g, b] = color
// Document shape
drawRect(pixels, 22, 14, 38, 54, r, g, b)
// Inner white area
drawRect(pixels, 28, 22, 26, 38, 255, 248, 240)
// Lines on document
drawRect(pixels, 31, 30, 20, 2, r, g, b)
drawRect(pixels, 31, 38, 16, 2, r, g, b)
drawRect(pixels, 31, 46, 20, 2, r, g, b)
drawRect(pixels, 31, 54, 12, 2, r, g, b)
// Corner fold
drawLine(pixels, 52, 14, 60, 22, r, g, b, 1)
}
function drawUser(pixels, color) {
const [r, g, b] = color
// Head (circle)
drawCircle(pixels, 40, 28, 12, r, g, b)
// Body (half circle / arc)
for (let x = -24; x <= 24; x++) {
const y = Math.round(Math.sqrt(Math.max(0, 24 * 24 - x * x)))
for (let dy = 0; dy < 3; dy++) {
setPixel(pixels, 40 + x, 58 - y + dy, r, g, b)
}
}
// Fill body
for (let y = 48; y <= 66; y++) {
for (let x = 16; x <= 64; x++) {
const dx = x - 40
const dy = y - 58
if (dy * dy + dx * dx <= 26 * 26) {
setPixel(pixels, x, y, r, g, b)
}
}
}
}
// Generate all icons
const icons = [
{ name: 'home', draw: drawHome },
{ name: 'chart', draw: drawChart },
{ name: 'bill', draw: drawBill },
{ name: 'user', draw: drawUser },
]
if (!fs.existsSync(OUT_DIR)) {
fs.mkdirSync(OUT_DIR, { recursive: true })
}
for (const icon of icons) {
// Inactive
const inactivePixels = makePixels(icon.draw, INACTIVE)
const inactivePNG = createPNG(inactivePixels)
fs.writeFileSync(path.join(OUT_DIR, `${icon.name}.png`), inactivePNG)
// Active
const activePixels = makePixels(icon.draw, ACTIVE)
const activePNG = createPNG(activePixels)
fs.writeFileSync(path.join(OUT_DIR, `${icon.name}-active.png`), activePNG)
console.log(`Generated: ${icon.name}.png, ${icon.name}-active.png`)
}
console.log('All icons generated successfully!')