feat: v1.2 数据可视化与统计增强
新增功能: - uCharts 集成,支持环形图和折线图 - 统计页分类占比环形图 - 统计页每日趋势折线图 - 统计页增加最大单笔和对比上月指标 - 首页今日支出卡片 组件: - ChartWrapper 通用图表组件(支持 pie/line/column) - 支持 H5 和微信小程序双端 依赖: - 新增 @qiun/ucharts 图表库
This commit is contained in:
93
client/src/components/ChartWrapper/ChartWrapper.vue
Normal file
93
client/src/components/ChartWrapper/ChartWrapper.vue
Normal 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>
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user