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 延迟
@@ -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 }
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
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>
|
<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']
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
<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)
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
BIN
client/src/static/icons/alert-circle-light.png
Normal file
|
After Width: | Height: | Size: 836 B |
BIN
client/src/static/icons/arrow-left-dark.png
Normal file
|
After Width: | Height: | Size: 367 B |
BIN
client/src/static/icons/arrow-left-gray.png
Normal file
|
After Width: | Height: | Size: 380 B |
BIN
client/src/static/icons/arrow-right-gray.png
Normal file
|
After Width: | Height: | Size: 375 B |
BIN
client/src/static/icons/arrow-right-pale.png
Normal file
|
After Width: | Height: | Size: 377 B |
BIN
client/src/static/icons/bell-gray.png
Normal file
|
After Width: | Height: | Size: 713 B |
BIN
client/src/static/icons/calendar-gray.png
Normal file
|
After Width: | Height: | Size: 378 B |
BIN
client/src/static/icons/check-white.png
Normal file
|
After Width: | Height: | Size: 372 B |
BIN
client/src/static/icons/chevron-right-light.png
Normal file
|
After Width: | Height: | Size: 345 B |
BIN
client/src/static/icons/edit-gray.png
Normal file
|
After Width: | Height: | Size: 719 B |
BIN
client/src/static/icons/edit-light.png
Normal file
|
After Width: | Height: | Size: 705 B |
BIN
client/src/static/icons/trash-orange.png
Normal file
|
After Width: | Height: | Size: 369 B |
BIN
client/src/static/icons/trash-red.png
Normal file
|
After Width: | Height: | Size: 354 B |
BIN
client/src/static/icons/trash-white.png
Normal file
|
After Width: | Height: | Size: 322 B |
BIN
client/src/static/icons/trend-down-red.png
Normal file
|
After Width: | Height: | Size: 504 B |
BIN
client/src/static/icons/trend-up-green.png
Normal file
|
After Width: | Height: | Size: 506 B |
BIN
client/src/static/icons/user-gray.png
Normal file
|
After Width: | Height: | Size: 557 B |
BIN
client/src/static/icons/user-white.png
Normal file
|
After Width: | Height: | Size: 480 B |
@@ -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
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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!')
|
|
||||||