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"> <script setup lang="ts">
import { computed } from 'vue' import { computed } from 'vue'
const props = defineProps<{ const props = withDefaults(defineProps<{
label: string label: string
color: string color: string
bgColor: string bgColor?: string
size?: 'sm' | 'md' | 'lg' size?: 'sm' | 'md' | 'lg'
selected?: boolean selected?: boolean
}>() }>(), { bgColor: '' })
const sizeMap = { sm: 64, md: 88, lg: 112 } const sizeMap = { sm: 64, md: 88, lg: 112 }
const fontMap = { sm: 28, md: 36, lg: 48 } const fontMap = { sm: 28, md: 36, lg: 48 }

View File

@@ -11,7 +11,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, watch, nextTick } from 'vue' import { ref, onMounted, watch, nextTick, getCurrentInstance } from 'vue'
// @ts-ignore // @ts-ignore
import uCharts from '@qiun/ucharts/u-charts.min.js' import uCharts from '@qiun/ucharts/u-charts.min.js'
@@ -60,8 +60,6 @@ function drawChart() {
}) })
} }
import { getCurrentInstance } from 'vue'
function onTap(e: any) { function onTap(e: any) {
chartInstance?.touchLegend(e) chartInstance?.touchLegend(e)
emit('tap', 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> <text class="key-text">{{ key }}</text>
</view> </view>
<view class="key fn" @tap="onDelete"> <view class="key fn" @tap="onDelete">
<SvgIcon name="trash" :size="36" color="#FF8C69" /> <Icon name="trash" :size="36" color="#FF8C69" />
</view> </view>
<view class="key eq" @tap="onConfirm"> <view class="key eq" @tap="onConfirm">
<SvgIcon name="check" :size="36" color="#FFFFFF" /> <Icon name="check" :size="36" color="#FFFFFF" />
</view> </view>
</view> </view>
</template> </template>
<script setup lang="ts"> <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'] const keys = ['1', '2', '3', '4', '5', '6', '7', '8', '9', '.', '0', '00']

View File

@@ -1,7 +1,7 @@
<template> <template>
<view class="save-success" v-if="visible" @tap="hide"> <view class="save-success" v-if="visible" @tap="hide">
<view class="success-icon" :class="{ show: animating }"> <view class="success-icon" :class="{ show: animating }">
<SvgIcon name="check" :size="80" color="#FFFFFF" /> <Icon name="check" :size="80" color="#FFFFFF" />
</view> </view>
<text class="success-text" :class="{ show: animating }">{{ text }}</text> <text class="success-text" :class="{ show: animating }">{{ text }}</text>
</view> </view>
@@ -9,7 +9,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, watch } from 'vue' import { ref, watch } from 'vue'
import SvgIcon from '@/components/SvgIcon/SvgIcon.vue' import Icon from '@/components/Icon/Icon.vue'
const props = withDefaults(defineProps<{ const props = withDefaults(defineProps<{
visible: boolean 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 <view
class="tx-item" class="tx-item"
:style="{ transform: `translateX(${offsetX}rpx)` }" :style="{ transform: `translateX(${offsetX}rpx)` }"
@tap="$emit('tap', item)" @tap="$emit('item-tap', item)"
@longpress="$emit('longpress', item)" @touchstart="!disableSwipe && onTouchStart($event)"
@touchstart="onTouchStart" @touchmove="!disableSwipe && onTouchMove($event)"
@touchmove="onTouchMove" @touchend="!disableSwipe && onTouchEnd()"
@touchend="onTouchEnd"
> >
<view class="icon-wrap" :style="{ background: item.category_color + '20' }"> <view class="icon-wrap" :style="{ background: item.category_color + '20' }">
<text class="icon-text" :style="{ color: item.category_color }">{{ item.category_name?.[0] || '?' }}</text> <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> <text class="amount" :class="item.type">{{ item.type === 'expense' ? '-' : '+' }}{{ formatAmount(item.amount) }}</text>
</view> </view>
<view class="delete-btn" :class="{ visible: showDelete }" @tap="$emit('delete', item)"> <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>
</view> </view>
</template> </template>
@@ -27,7 +26,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue' import { ref } from 'vue'
import { formatAmount, formatDate } from '@/utils/format' import { formatAmount, formatDate } from '@/utils/format'
import SvgIcon from '@/components/SvgIcon/SvgIcon.vue' import Icon from '@/components/Icon/Icon.vue'
withDefaults(defineProps<{ withDefaults(defineProps<{
item: { item: {
@@ -41,9 +40,11 @@ withDefaults(defineProps<{
category_color?: string category_color?: string
} }
hideDate?: boolean hideDate?: boolean
}>(), { hideDate: false }) /** 禁用滑动删除(首页预览等场景) */
disableSwipe?: boolean
}>(), { hideDate: false, disableSwipe: false })
defineEmits(['tap', 'longpress', 'delete']) defineEmits(['item-tap', 'delete'])
const startX = ref(0) const startX = ref(0)
const offsetX = ref(0) const offsetX = ref(0)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,15 +1,17 @@
<template> <template>
<view class="page"> <view class="page">
<view class="status-bar" :style="{ height: statusBarHeight + 'px' }"></view> <view class="header-fixed">
<text class="page-title">统计分析</text> <view class="status-bar" :style="{ height: statusBarHeight + 'px' }"></view>
<text class="page-title">统计分析</text>
</view>
<view class="month-picker"> <view class="month-picker">
<view class="arrow" @tap="prevMonth"> <view class="arrow" @tap="prevMonth">
<SvgIcon name="arrowLeft" :size="32" color="#8B7E7E" /> <Icon name="arrowLeft" :size="32" color="#8B7E7E" />
</view> </view>
<text class="month-text">{{ formatMonth(currentMonth) }}</text> <text class="month-text">{{ formatMonth(currentMonth) }}</text>
<view class="arrow" :class="{ disabled: isCurrentMonth }" @tap="nextMonth"> <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>
</view> </view>
@@ -74,7 +76,7 @@
</view> </view>
<view class="state-box" v-if="loadError"> <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> <text class="state-text">加载失败请稍后重试</text>
</view> </view>
@@ -121,7 +123,7 @@ import { onPullDownRefresh } from '@dcloudio/uni-app'
import { useStatsStore } from '@/stores/stats' import { useStatsStore } from '@/stores/stats'
import { formatAmount, formatMonth, getCurrentMonth } from '@/utils/format' import { formatAmount, formatMonth, getCurrentMonth } from '@/utils/format'
import { statusBarHeight } from '@/utils/system' 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 Skeleton from '@/components/Skeleton/Skeleton.vue'
import ChartWrapper from '@/components/ChartWrapper/ChartWrapper.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() { async function loadTabData() {
try { try {
await Promise.all([ await Promise.all([
statsStore.fetchOverview(currentMonth.value),
statsStore.fetchCategoryStats(currentMonth.value, tabType.value), statsStore.fetchCategoryStats(currentMonth.value, tabType.value),
statsStore.fetchTrend(currentMonth.value, tabType.value), statsStore.fetchTrend(currentMonth.value, tabType.value),
fetchMaxSingle(), fetchMaxSingle(),
fetchPrevMonthData() fetchPrevMonthData()
]) ])
overview.value = statsStore.overview
categoryStats.value = statsStore.categoryStats categoryStats.value = statsStore.categoryStats
trendData.value = statsStore.trendData trendData.value = statsStore.trendData
} catch (e) { } catch (e) {
@@ -311,6 +315,13 @@ function getBarWidth(amount: number) {
padding: 0 0 180rpx; padding: 0 0 180rpx;
} }
.header-fixed {
position: sticky;
top: 0;
z-index: 100;
background: #FFF8F0;
}
.status-bar { .status-bar {
background: #FFF8F0; 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 基础地址 - 上线后改成真实域名 // API 基础地址
let BASE_URL = 'http://106.14.208.43:3000/api' const BASE_URL = 'https://xiaocai.j35.site/api'
if (typeof wx !== 'undefined' && wx.getSystemInfoSync) {
// 微信小程序环境使用 HTTPS 域名(上线后配置)
// BASE_URL = 'https://your-domain.com/api'
}
interface RequestOptions { interface RequestOptions {
url: string url: string
@@ -48,10 +44,12 @@ export function request<T = any>(options: RequestOptions): Promise<T> {
reject({ code: 40100, message: '未登录' }) reject({ code: 40100, message: '未登录' })
}).finally(() => { isRedirectingToLogin = false }) }).finally(() => { isRedirectingToLogin = false })
} else { } else {
// 小程序环境重新触发 wx.login // 小程序环境自动重新登录
uni.showToast({ title: '请重新登录', icon: 'none' }) reLogin().then(() => {
setTimeout(() => { reLogin(); isRedirectingToLogin = false }, 1500) request(options).then(resolve).catch(reject)
reject({ code: 40100, message: '未登录' }) }).catch(() => {
reject({ code: 40100, message: '未登录' })
}).finally(() => { isRedirectingToLogin = false })
} }
} else { } else {
reject({ code: 40100, message: '未登录' }) reject({ code: 40100, message: '未登录' })
@@ -76,25 +74,38 @@ export function request<T = any>(options: RequestOptions): Promise<T> {
} }
// 微信小程序自动重新登录 // 微信小程序自动重新登录
function reLogin() { function reLogin(): Promise<void> {
// #ifdef MP-WEIXIN return new Promise((resolve, reject) => {
uni.login({ // #ifdef MP-WEIXIN
provider: 'weixin', uni.login({
success: async (loginRes) => { provider: 'weixin',
if (loginRes.code) { success: async (loginRes) => {
try { if (loginRes.code) {
const data = await request<{ token: string; userId: number }>({ try {
url: '/auth/login', const data = await request<{ token: string; userId: number }>({
method: 'POST', url: '/auth/login',
data: { code: loginRes.code } method: 'POST',
}) data: { code: loginRes.code }
uni.setStorageSync('xc:token', data.token) })
uni.setStorageSync('xc:userId', data.userId) uni.setStorageSync('xc:token', data.token)
} catch (e) { uni.setStorageSync('xc:userId', data.userId)
console.error('[Auth] Re-login failed:', e) 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!')