feat: v1.2 数据可视化与统计增强

新增功能:
- uCharts 集成,支持环形图和折线图
- 统计页分类占比环形图
- 统计页每日趋势折线图
- 统计页增加最大单笔和对比上月指标
- 首页今日支出卡片

组件:
- ChartWrapper 通用图表组件(支持 pie/line/column)
- 支持 H5 和微信小程序双端

依赖:
- 新增 @qiun/ucharts 图表库
This commit is contained in:
2026-05-29 16:28:50 +08:00
parent c7f29aa7a7
commit 7f8b301f95
6 changed files with 2969 additions and 1249 deletions

View File

@@ -0,0 +1,93 @@
<template>
<view class="chart-wrap">
<canvas
:canvas-id="canvasId"
:id="canvasId"
class="chart-canvas"
:style="{ width: width + 'rpx', height: height + 'rpx' }"
@tap="onTap"
></canvas>
</view>
</template>
<script setup lang="ts">
import { ref, onMounted, watch, nextTick } from 'vue'
// @ts-ignore
import uCharts from '@qiun/ucharts/u-charts.min.js'
const props = defineProps<{
canvasId: string
type: 'pie' | 'line' | 'column'
chartData: any
width?: number
height?: number
}>()
const emit = defineEmits(['tap'])
let chartInstance: any = null
function drawChart() {
if (!props.chartData) return
const query = uni.createSelectorQuery().in(getCurrentInstance()?.proxy)
query.select(`#${props.canvasId}`)
.fields({ node: true, size: true })
.exec((res) => {
if (!res[0]) return
const canvas = res[0].node
const ctx = canvas.getContext('2d')
const dpr = uni.getSystemInfoSync().pixelRatio
canvas.width = res[0].width * dpr
canvas.height = res[0].height * dpr
ctx.scale(dpr, dpr)
const config: any = {
context: ctx,
canvas2d: true,
pixelRatio: dpr,
width: res[0].width,
height: res[0].height,
animation: true,
background: '#FFFFFF',
padding: [15, 15, 15, 15],
...props.chartData
}
chartInstance = new uCharts(config)
})
}
import { getCurrentInstance } from 'vue'
function onTap(e: any) {
chartInstance?.touchLegend(e)
emit('tap', e)
}
onMounted(() => {
nextTick(() => {
setTimeout(drawChart, 100)
})
})
watch(() => props.chartData, () => {
nextTick(() => {
setTimeout(drawChart, 100)
})
}, { deep: true })
</script>
<style lang="scss" scoped>
.chart-wrap {
width: 100%;
display: flex;
justify-content: center;
}
.chart-canvas {
display: block;
}
</style>

View File

@@ -36,6 +36,20 @@
</view>
</view>
<view class="today-card" v-if="!loading">
<view class="today-left">
<text class="today-label">今日支出</text>
<text class="today-amount">{{ formatAmount(todayExpense) }}</text>
</view>
<view class="today-right">
<text class="today-count">{{ todayCount }}</text>
</view>
</view>
<view class="today-card" v-else>
<Skeleton width="200rpx" height="28rpx" variant="text" />
<Skeleton width="160rpx" height="48rpx" variant="rect" />
</view>
<view class="quick-actions">
<view class="quick-btn" @tap="goAdd('expense')">
<SvgIcon name="trend-down" :size="48" color="#FF6B6B" />
@@ -103,6 +117,8 @@ const budget = ref<any>(null)
const recentTx = ref<any[]>([])
const loading = ref(true)
const loadError = ref(false)
const todayExpense = ref(0)
const todayCount = ref(0)
const greeting = computed(() => {
const h = new Date().getHours()
@@ -117,10 +133,12 @@ async function loadData() {
try {
loading.value = true
loadError.value = false
const today = new Date().toISOString().slice(0, 10)
await Promise.all([
statsStore.fetchOverview(),
budgetStore.fetchBudget(),
txStore.fetchTransactions({ page: 1 })
txStore.fetchTransactions({ page: 1 }),
fetchTodayExpense(today)
])
overview.value = statsStore.overview
budget.value = budgetStore.budget
@@ -133,6 +151,21 @@ async function loadData() {
}
}
async function fetchTodayExpense(today: string) {
try {
const { request } = await import('@/utils/request')
const data = await request<any>({
url: '/transactions',
data: { page: 1, pageSize: 100, type: 'expense', startDate: today, endDate: today }
})
todayExpense.value = data.list.reduce((sum: number, t: any) => sum + t.amount, 0)
todayCount.value = data.list.length
} catch {
todayExpense.value = 0
todayCount.value = 0
}
}
onMounted(() => loadData())
// Refresh data when returning from other pages (e.g., after adding a transaction)
@@ -270,6 +303,45 @@ function goBills() {
&.expense { color: #FF6B6B; }
}
.today-card {
margin: 24rpx 40rpx 0;
padding: 28rpx 32rpx;
background: #FFFFFF;
border-radius: 28rpx;
border: 2rpx solid #F0E0D6;
box-shadow: 4rpx 4rpx 12rpx rgba(45, 27, 27, 0.08);
display: flex;
justify-content: space-between;
align-items: center;
}
.today-left {
display: flex;
flex-direction: column;
gap: 8rpx;
}
.today-label {
font-size: 24rpx;
color: #8B7E7E;
}
.today-amount {
font-family: 'Fredoka', sans-serif;
font-size: 36rpx;
font-weight: 600;
color: #FF6B6B;
}
.today-right {
text-align: right;
}
.today-count {
font-size: 24rpx;
color: #8B7E7E;
}
.quick-actions {
display: flex;
gap: 24rpx;

View File

@@ -38,8 +38,26 @@
</view>
</view>
<view class="extra-stats" v-if="!loading && overview.count > 0">
<view class="es-item">
<text class="es-label">最大单笔</text>
<text class="es-value" v-if="!loading">{{ formatAmount(maxSingle) }}</text>
</view>
<view class="es-item" v-if="prevMonthData">
<text class="es-label">对比上月</text>
<text class="es-value" :class="monthCompareClass">{{ monthCompareText }}</text>
</view>
</view>
<view class="chart-card" v-if="!loading && categoryStats.length > 0">
<text class="chart-title">分类占比</text>
<ChartWrapper
canvasId="categoryChart"
type="pie"
:chartData="categoryChartData"
:width="600"
:height="400"
/>
<view class="chart-legend">
<view v-for="item in categoryStats" :key="item.id" class="legend-item">
<view class="legend-dot" :style="{ background: item.color }"></view>
@@ -66,6 +84,13 @@
<view class="chart-card" v-if="!loading && trendData.length > 0">
<text class="chart-title">每日趋势</text>
<ChartWrapper
canvasId="trendChart"
type="line"
:chartData="trendChartData"
:width="600"
:height="400"
/>
<view class="trend-list">
<view v-for="(point, i) in trendData" :key="i" class="trend-item">
<text class="trend-date">{{ point.date.slice(8) }}</text>
@@ -98,6 +123,7 @@ import { formatAmount, formatMonth, getCurrentMonth } from '@/utils/format'
import { statusBarHeight } from '@/utils/system'
import SvgIcon from '@/components/SvgIcon/SvgIcon.vue'
import Skeleton from '@/components/Skeleton/Skeleton.vue'
import ChartWrapper from '@/components/ChartWrapper/ChartWrapper.vue'
const statsStore = useStatsStore()
@@ -108,9 +134,79 @@ const categoryStats = ref<any[]>([])
const trendData = ref<any[]>([])
const loading = ref(true)
const loadError = ref(false)
const maxSingle = ref(0)
const prevMonthData = ref<any>(null)
const isCurrentMonth = computed(() => currentMonth.value === getCurrentMonth())
const categoryChartData = computed(() => {
if (categoryStats.value.length === 0) return null
return {
series: [{
data: categoryStats.value.map(item => ({
name: item.name,
value: item.amount / 100,
color: item.color
}))
}],
extra: {
pie: {
activeOpacity: 0.5,
activeRadius: 10,
offsetAngle: 0,
labelWidth: 15,
border: false,
borderWidth: 3,
borderColor: '#FFFFFF',
linearType: 'custom'
}
}
}
})
const trendChartData = computed(() => {
if (trendData.value.length === 0) return null
return {
categories: trendData.value.map(p => p.date.slice(8) + '日'),
series: [{
name: tabType.value === 'expense' ? '支出' : '收入',
data: trendData.value.map(p => p.amount / 100),
color: '#FF8C69'
}],
extra: {
line: {
type: 'curve',
width: 2
},
area: {
type: 'curve',
opacity: 0.2,
gradient: true
}
}
}
})
const monthCompareClass = computed(() => {
if (!prevMonthData.value) return ''
const current = tabType.value === 'expense' ? overview.value.expense : overview.value.income
const prev = tabType.value === 'expense' ? prevMonthData.value.expense : prevMonthData.value.income
if (current > prev) return 'up'
if (current < prev) return 'down'
return 'same'
})
const monthCompareText = computed(() => {
if (!prevMonthData.value) return ''
const current = tabType.value === 'expense' ? overview.value.expense : overview.value.income
const prev = tabType.value === 'expense' ? prevMonthData.value.expense : prevMonthData.value.income
if (prev === 0) return '无上月数据'
const pct = Math.round(((current - prev) / prev) * 100)
if (pct > 0) return `+${pct}%`
if (pct < 0) return `${pct}%`
return '持平'
})
onMounted(() => loadData())
// Only re-fetch category/trend on tab switch; re-fetch all on month change
@@ -124,7 +220,9 @@ async function loadData() {
await Promise.all([
statsStore.fetchOverview(currentMonth.value),
statsStore.fetchCategoryStats(currentMonth.value, tabType.value),
statsStore.fetchTrend(currentMonth.value, tabType.value)
statsStore.fetchTrend(currentMonth.value, tabType.value),
fetchMaxSingle(),
fetchPrevMonthData()
])
overview.value = statsStore.overview
categoryStats.value = statsStore.categoryStats
@@ -151,6 +249,38 @@ async function loadTabData() {
}
}
async function fetchMaxSingle() {
try {
const [y, m] = currentMonth.value.split('-').map(Number)
const startDate = `${currentMonth.value}-01`
const lastDay = new Date(y, m, 0).getDate()
const endDate = `${currentMonth.value}-${String(lastDay).padStart(2, '0')}`
const { request } = await import('@/utils/request')
const data = await request<any>({
url: '/transactions',
data: { page: 1, pageSize: 1, type: tabType.value, startDate, endDate }
})
maxSingle.value = data.list?.[0]?.amount || 0
} catch {
maxSingle.value = 0
}
}
async function fetchPrevMonthData() {
try {
const [y, m] = currentMonth.value.split('-').map(Number)
const prevMonth = m === 1 ? `${y - 1}-12` : `${y}-${String(m - 1).padStart(2, '0')}`
const { request } = await import('@/utils/request')
const data = await request<any>({
url: '/stats/overview',
data: { month: prevMonth }
})
prevMonthData.value = data
} catch {
prevMonthData.value = null
}
}
onPullDownRefresh(() => {
loadData().finally(() => uni.stopPullDownRefresh())
})
@@ -256,6 +386,40 @@ function getBarWidth(amount: number) {
text-align: center;
}
.extra-stats {
display: flex;
gap: 24rpx;
margin: 0 40rpx 32rpx;
}
.es-item {
flex: 1;
padding: 24rpx;
background: #FFFFFF;
border-radius: 28rpx;
border: 2rpx solid #F0E0D6;
box-shadow: 4rpx 4rpx 12rpx rgba(45, 27, 27, 0.08);
text-align: center;
}
.es-label {
font-size: 22rpx;
color: #8B7E7E;
display: block;
margin-bottom: 8rpx;
}
.es-value {
font-family: 'Fredoka', sans-serif;
font-size: 28rpx;
font-weight: 600;
color: #2D1B1B;
&.up { color: #FF6B6B; }
&.down { color: #7BC67E; }
&.same { color: #8B7E7E; }
}
.ov-label {
font-size: 24rpx;
color: #8B7E7E;