Files
ledgerly/frontend/src/views/Dashboard.vue
T
rosercode 5fb3d78ce0 feat: 仪表盘新增收入分类饼图并拆分收支支付方式占比
- 仪表盘新增「收入分类占比」饼图,支付方式拆分为支出、收入两张饼图
- 支付方式标签按当前语言本地化,仅统计对应方向的记录
- 收支记录中不计入统计的记录整行加删除线(类型、金额列除外)
- 补充中/英/日三语文案
2026-09-20 01:46:14 +08:00

597 lines
22 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
// 统计面板组件:展示指定时间范围的收支汇总、概览指标、趋势、分类占比与支付方式分布
import { ref, computed, onMounted, watch, nextTick, h } from 'vue'
import { useI18n } from 'vue-i18n'
import { NCard, NGrid, NGridItem, NStatistic, NSpace, NSelect, NDatePicker, NInput, NInputNumber, NButton } from 'naive-ui'
import VChart from 'vue-echarts'
import { use } from 'echarts/core'
import { CanvasRenderer } from 'echarts/renderers'
import { LineChart, PieChart, BarChart } from 'echarts/charts'
import { TitleComponent, TooltipComponent, LegendComponent, GridComponent } from 'echarts/components'
import { getStatisticsSummary, getStatisticsGroup } from '../api/statistics'
import { getCategoryList } from '../api/category'
import { getPaymentMethods } from '../api/transaction'
import { getPaymentLabel } from '../utils/payment'
// 注册 ECharts 所需组件
use([CanvasRenderer, LineChart, PieChart, BarChart, TitleComponent, TooltipComponent, LegendComponent, GridComponent])
const { t, locale } = useI18n() // 国际化
// 时间范围快捷选项
const rangeOptions = computed(() => [
{ label: t('dashboard.range.month'), value: 'month' },
{ label: t('dashboard.range.3month'), value: '3month' },
{ label: t('dashboard.range.12month'), value: '12month' },
{ label: t('dashboard.range.custom'), value: 'custom' },
])
// 趋势图时间粒度选项
const granularityOptions = computed(() => [
{ label: t('dashboard.granularity.day'), value: 'day' },
{ label: t('dashboard.granularity.week'), value: 'week' },
{ label: t('dashboard.granularity.month'), value: 'month' },
{ label: t('dashboard.granularity.quarter'), value: 'quarter' },
{ label: t('dashboard.granularity.year'), value: 'year' },
])
const rangeType = ref<'month' | '3month' | '12month' | 'custom'>('month') // 时间范围类型
const customRange = ref<[number, number] | null>(null) // 自定义起止时间戳
const granularity = ref<'day' | 'week' | 'month' | 'quarter' | 'year'>('day') // 趋势粒度
// 过滤条件
const filterStatsScope = ref<'INCLUDED' | 'ALL' | 'EXCLUDED'>('INCLUDED') // 统计口径
const filterType = ref<string[]>([]) // 类型(多选)
const filterCategory = ref<number[]>([]) // 分类(多选,0 表示未分类)
const filterPaymentMethod = ref<string[]>([]) // 支付方式(多选)
const filterMinAmount = ref<number | null>(null) // 最小金额
const filterMaxAmount = ref<number | null>(null) // 最大金额
const filterRemark = ref('') // 备注关键字
// 统计口径选项:仅计入 / 全部 / 仅不计入
const statsScopeOptions = computed(() => [
{ label: t('dashboard.statsScope.included'), value: 'INCLUDED' },
{ label: t('dashboard.statsScope.all'), value: 'ALL' },
{ label: t('dashboard.statsScope.excluded'), value: 'EXCLUDED' },
])
// 类型与支付方式选项
const typeOptions = computed(() => [
{ label: t('common.types.expense'), value: 'EXPENSE' },
{ label: t('common.types.income'), value: 'INCOME' },
])
// 支付方式编码列表(从后端动态获取,含账单导入来源与人工支付方式)
const paymentMethodCodes = ref<string[]>([])
// 支付方式选项:由后端返回的编码映射为当前语言标签,locale 变化时重新生成
const paymentOptions = computed(() => {
void locale.value
return paymentMethodCodes.value.map((code) => ({ label: getPaymentLabel(code), value: code }))
})
/** 分类选项数据结构 */
interface CategoryOption {
label: string
value: number
type: string
icon: string
}
const categories = ref<CategoryOption[]>([]) // 分类选项列表
// 分类筛选项:固定包含「未分类」,其余分类按类型筛选联动
const categoryFilterOptions = computed(() => [
{ label: t('dashboard.uncategorized'), value: 0, type: '' },
...categories.value.filter(c => filterType.value.length === 0 || filterType.value.includes(c.type)),
])
/** 分类下拉项渲染:图标 + 名称 */
function renderCategoryLabel(option: any) {
return h('span', null, [
option.icon ? h('span', { style: { marginRight: '6px' } }, option.icon) : null,
option.label,
])
}
/** 将分类筛选拆分为分类ID与未分类标记 */
function buildCategoryFilter() {
const ids = filterCategory.value.filter((v) => v !== 0)
return {
categoryIds: ids.length > 0 ? ids : undefined,
uncategorized: filterCategory.value.includes(0) ? true : undefined,
}
}
/** 统计接口的过滤条件 */
const filterParams = computed(() => ({
statsScope: filterStatsScope.value,
types: filterType.value.length > 0 ? filterType.value : undefined,
...buildCategoryFilter(),
paymentMethods: filterPaymentMethod.value.length > 0 ? filterPaymentMethod.value : undefined,
minAmount: filterMinAmount.value ?? undefined,
maxAmount: filterMaxAmount.value ?? undefined,
remark: filterRemark.value.trim() || undefined,
}))
// 统计结果
const summary = ref<any>({
totalIncome: 0, totalExpense: 0, balance: 0, transactionCount: 0,
dayCount: 0, avgDailyIncome: 0, avgDailyExpense: 0,
maxExpenseAmount: 0, maxExpenseTime: null, maxExpenseRemark: '', avgExpenseAmount: 0,
})
const trendData = ref<any[]>([]) // 趋势分组数据
const categoryData = ref<any[]>([]) // 分类分组数据
const paymentData = ref<any[]>([]) // 支付方式分组数据
const pieOption = ref({}) // 支出分类饼图
const incomePieOption = ref({}) // 收入分类饼图
const paymentOption = ref({}) // 支出支付方式饼图
const incomePaymentOption = ref({}) // 收入支付方式饼图
const trendOption = ref({}) // 趋势柱状图
const pad = (n: number) => String(n).padStart(2, '0')
/** 格式化日期为查询用字符串 */
function fmt(d: Date, end = false): string {
const date = `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
return end ? `${date}T23:59:59` : `${date}T00:00:00`
}
/** 根据快捷选项与自定义区间计算查询起止时间 */
const range = computed(() => {
if (rangeType.value === 'custom' && customRange.value) {
return {
startTime: fmt(new Date(customRange.value[0])),
endTime: fmt(new Date(customRange.value[1]), true),
}
}
const now = new Date()
const end = new Date(now.getFullYear(), now.getMonth() + 1, 0) // 本月最后一天
let start: Date
if (rangeType.value === '12month') {
start = new Date(now.getFullYear(), now.getMonth() - 11, 1)
} else if (rangeType.value === '3month') {
start = new Date(now.getFullYear(), now.getMonth() - 2, 1)
} else {
start = new Date(now.getFullYear(), now.getMonth(), 1)
}
return { startTime: fmt(start), endTime: fmt(end, true) }
})
/** 计算 ISO 周标签,与后端 %x-W%v 对齐 */
function isoWeekKey(d: Date): string {
const date = new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()))
const dayNum = date.getUTCDay() || 7
date.setUTCDate(date.getUTCDate() + 4 - dayNum)
const yearStart = new Date(Date.UTC(date.getUTCFullYear(), 0, 1))
const weekNo = Math.ceil(((date.getTime() - yearStart.getTime()) / 86400000 + 1) / 7)
return `${date.getUTCFullYear()}-W${pad(weekNo)}`
}
/** 按粒度生成区间内完整的分组键序列(用于补全无数据的点) */
function buildKeys(start: Date, end: Date, gran: string): string[] {
const keys: string[] = []
if (gran === 'day') {
for (const d = new Date(start); d <= end; d.setDate(d.getDate() + 1)) {
keys.push(`${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`)
}
} else if (gran === 'week') {
const seen = new Set<string>()
for (const d = new Date(start); d <= end; d.setDate(d.getDate() + 1)) {
const k = isoWeekKey(d)
if (!seen.has(k)) { seen.add(k); keys.push(k) }
}
} else if (gran === 'month') {
for (const d = new Date(start.getFullYear(), start.getMonth(), 1); d <= end; d.setMonth(d.getMonth() + 1)) {
keys.push(`${d.getFullYear()}-${pad(d.getMonth() + 1)}`)
}
} else if (gran === 'quarter') {
const seen = new Set<string>()
for (const d = new Date(start.getFullYear(), start.getMonth(), 1); d <= end; d.setMonth(d.getMonth() + 1)) {
const k = `${d.getFullYear()}-Q${Math.floor(d.getMonth() / 3) + 1}`
if (!seen.has(k)) { seen.add(k); keys.push(k) }
}
} else {
for (let y = start.getFullYear(); y <= end.getFullYear(); y++) keys.push(String(y))
}
return keys
}
/** 更新支出分类饼图 */
function updatePieChart() {
pieOption.value = {
title: { text: t('dashboard.expenseCategoryPie'), left: 'center' },
tooltip: { trigger: 'item', formatter: '{b}: {c} ({d}%)' },
series: [
{
type: 'pie',
radius: ['40%', '70%'],
data: categoryData.value
.filter((c: any) => c.type === 'EXPENSE')
.map((c: any) => ({ name: c.name, value: Number(c.total) })),
},
],
}
}
/** 更新收入分类饼图 */
function updateIncomePieChart() {
incomePieOption.value = {
title: { text: t('dashboard.incomeCategoryPie'), left: 'center' },
tooltip: { trigger: 'item', formatter: '{b}: {c} ({d}%)' },
series: [
{
type: 'pie',
radius: ['40%', '70%'],
data: categoryData.value
.filter((c: any) => c.type === 'INCOME')
.map((c: any) => ({ name: c.name, value: Number(c.total) })),
},
],
}
}
/** 更新支出支付方式占比饼图:仅统计支出,标签按当前语言本地化 */
function updatePaymentChart() {
paymentOption.value = {
title: { text: t('dashboard.expensePaymentPie'), left: 'center' },
tooltip: { trigger: 'item', formatter: '{b}: {c} ({d}%)' },
series: [
{
type: 'pie',
radius: ['40%', '70%'],
data: paymentData.value
.filter((p: any) => Number(p.expense) > 0)
.map((p: any) => ({ name: getPaymentLabel(p.key), value: Number(p.expense) })),
},
],
}
}
/** 更新收入支付方式占比饼图:仅统计收入,标签按当前语言本地化 */
function updateIncomePaymentChart() {
incomePaymentOption.value = {
title: { text: t('dashboard.incomePaymentPie'), left: 'center' },
tooltip: { trigger: 'item', formatter: '{b}: {c} ({d}%)' },
series: [
{
type: 'pie',
radius: ['40%', '70%'],
data: paymentData.value
.filter((p: any) => Number(p.income) > 0)
.map((p: any) => ({ name: getPaymentLabel(p.key), value: Number(p.income) })),
},
],
}
}
/** 更新每日/周/月收支趋势柱状图(补全缺失点) */
function updateTrendChart() {
const { startTime, endTime } = range.value
const start = new Date(startTime.replace('T', ' ').replace(/-/g, '/'))
const end = new Date(endTime.replace('T', ' ').replace(/-/g, '/'))
const keys = buildKeys(start, end, granularity.value)
const map = new Map<string, any>(trendData.value.map((d: any) => [d.key, d]))
const income = keys.map((k) => Number(map.get(k)?.income ?? 0))
const expense = keys.map((k) => Number(map.get(k)?.expense ?? 0))
const labels = keys.map((k) => (granularity.value === 'day' ? k.substring(5) : k))
trendOption.value = {
tooltip: { trigger: 'axis' },
legend: { data: [t('common.types.income'), t('common.types.expense')], bottom: 0 },
grid: { left: 60, right: 20, top: 40, bottom: 40 },
xAxis: { type: 'category', data: labels },
yAxis: { type: 'value' },
series: [
{ name: t('common.types.income'), type: 'bar', data: income, color: '#18a058' },
{ name: t('common.types.expense'), type: 'bar', data: expense, color: '#d03050' },
],
}
}
/** 并行请求所有统计数据并更新图表 */
async function fetchData() {
const { startTime, endTime } = range.value
const filters = filterParams.value
try {
const [summaryRes, trendRes, categoryRes, paymentRes]: any[] = await Promise.all([
getStatisticsSummary({ startTime, endTime, ...filters }),
getStatisticsGroup({ dimension: granularity.value, startTime, endTime, ...filters }),
getStatisticsGroup({ dimension: 'category', startTime, endTime, ...filters }),
getStatisticsGroup({ dimension: 'paymentMethod', startTime, endTime, ...filters }),
])
summary.value = summaryRes.data
trendData.value = trendRes.data
categoryData.value = categoryRes.data
paymentData.value = paymentRes.data
updatePieChart()
updateIncomePieChart()
updatePaymentChart()
updateIncomePaymentChart()
updateTrendChart()
} catch (e: any) {
console.error('加载统计数据失败', e)
}
}
/** 面板偏好存储键 */
const DASHBOARD_PREFS_KEY = 'finance.dashboard.prefs'
let suppressRangeWatch = false // 还原偏好时跳过 rangeType 对粒度的默认覆盖
/** 读取面板偏好并还原(时间范围、粒度、过滤条件) */
function loadPrefs() {
try {
const raw = localStorage.getItem(DASHBOARD_PREFS_KEY)
if (!raw) return
const p = JSON.parse(raw)
suppressRangeWatch = true
if (['month', '3month', '12month', 'custom'].includes(p.rangeType)) rangeType.value = p.rangeType
if (Array.isArray(p.customRange) && p.customRange.length === 2) customRange.value = p.customRange
if (['INCLUDED', 'ALL', 'EXCLUDED'].includes(p.filterStatsScope)) filterStatsScope.value = p.filterStatsScope
if (Array.isArray(p.filterType)) filterType.value = p.filterType
if (Array.isArray(p.filterCategory)) filterCategory.value = p.filterCategory
if (Array.isArray(p.filterPaymentMethod)) filterPaymentMethod.value = p.filterPaymentMethod
if (typeof p.filterMinAmount === 'number') filterMinAmount.value = p.filterMinAmount
if (typeof p.filterMaxAmount === 'number') filterMaxAmount.value = p.filterMaxAmount
if (typeof p.filterRemark === 'string') filterRemark.value = p.filterRemark
if (['day', 'week', 'month', 'quarter', 'year'].includes(p.granularity)) granularity.value = p.granularity
} catch (e) {
// 忽略损坏的偏好数据
}
}
/** 保存面板偏好,下次进入自动还原 */
function savePrefs() {
try {
localStorage.setItem(DASHBOARD_PREFS_KEY, JSON.stringify({
rangeType: rangeType.value,
customRange: customRange.value,
granularity: granularity.value,
filterStatsScope: filterStatsScope.value,
filterType: filterType.value,
filterCategory: filterCategory.value,
filterPaymentMethod: filterPaymentMethod.value,
filterMinAmount: filterMinAmount.value,
filterMaxAmount: filterMaxAmount.value,
filterRemark: filterRemark.value,
}))
} catch (e) {
// 忽略写入失败
}
}
/** 加载分类选项 */
async function fetchCategories() {
try {
const res: any = await getCategoryList()
categories.value = res.data.map((c: any) => ({ label: c.name, value: c.id, type: c.type, icon: c.icon || '' }))
} catch (e) {
// 静默处理
}
}
/** 从后端加载支付方式列表,供筛选下拉使用 */
async function loadPaymentMethods() {
try {
const res: any = await getPaymentMethods()
paymentMethodCodes.value = res.data || []
} catch (e) {
// 静默处理,支付方式下拉保持为空
}
}
/** 重置所有过滤条件(保留时间范围与粒度) */
function handleResetFilters() {
filterStatsScope.value = 'INCLUDED'
filterType.value = []
filterCategory.value = []
filterPaymentMethod.value = []
filterMinAmount.value = null
filterMaxAmount.value = null
filterRemark.value = ''
}
// 切换时间范围时给出默认粒度(还原偏好时不覆盖)
watch(rangeType, (v) => {
if (suppressRangeWatch) {
suppressRangeWatch = false
return
}
granularity.value = v === '12month' ? 'month' : v === '3month' ? 'week' : 'day'
})
// 范围、粒度或过滤条件变化时重新加载
watch([() => range.value.startTime, () => range.value.endTime, granularity, filterParams], fetchData)
// 任一偏好变化时自动保存
watch([rangeType, customRange, granularity, filterStatsScope, filterType, filterCategory, filterPaymentMethod, filterMinAmount, filterMaxAmount, filterRemark], savePrefs, { deep: true })
// 语言切换时重绘图表,更新标题与图例文案
watch(locale, () => {
updatePieChart()
updateIncomePieChart()
updatePaymentChart()
updateIncomePaymentChart()
updateTrendChart()
})
onMounted(async () => {
loadPrefs()
fetchCategories()
loadPaymentMethods()
await nextTick()
suppressRangeWatch = false
fetchData()
})
</script>
<template>
<div>
<!-- 顶部:标题与时间范围选择 -->
<NSpace justify="space-between" align="center" style="margin-bottom: 20px;" :wrap="true">
<h2 style="margin: 0;">{{ t('dashboard.title') }}</h2>
<NSpace align="center" :wrap="true">
<NSelect v-model:value="rangeType" :options="rangeOptions" style="width: 110px;" />
<NDatePicker
v-if="rangeType === 'custom'"
v-model:value="customRange"
type="daterange"
clearable
style="width: 260px;"
/>
<span style="color: #999; font-size: 12px;">{{ range.startTime.substring(0, 10) }} ~ {{ range.endTime.substring(0, 10) }}</span>
</NSpace>
</NSpace>
<!-- 过滤条件(偏好自动记忆) -->
<NCard :bordered="false" style="margin-bottom: 16px;">
<NSpace align="center" :wrap="true" :size="[12, 12]">
<NSelect
v-model:value="filterStatsScope"
:options="statsScopeOptions"
:placeholder="t('dashboard.filterStatsScope')"
style="width: 160px;"
/>
<NSelect
v-model:value="filterType"
:options="typeOptions"
multiple
clearable
:max-tag-count="2"
:placeholder="t('dashboard.filterType')"
style="width: 160px;"
/>
<NSelect
v-model:value="filterCategory"
:options="categoryFilterOptions"
multiple
clearable
filterable
:max-tag-count="2"
:render-label="renderCategoryLabel"
:placeholder="t('dashboard.filterCategory')"
style="width: 200px;"
/>
<NSelect
v-model:value="filterPaymentMethod"
:options="paymentOptions"
multiple
clearable
filterable
:max-tag-count="2"
:placeholder="t('dashboard.filterPaymentMethod')"
style="width: 200px;"
/>
<NInputNumber
v-model:value="filterMinAmount"
:min="0"
:precision="2"
clearable
:placeholder="t('dashboard.minAmount')"
style="width: 130px;"
/>
<span style="color: #999;">~</span>
<NInputNumber
v-model:value="filterMaxAmount"
:min="0"
:precision="2"
clearable
:placeholder="t('dashboard.maxAmount')"
style="width: 130px;"
/>
<NInput
v-model:value="filterRemark"
clearable
:placeholder="t('dashboard.remark')"
style="width: 180px;"
/>
<NButton @click="handleResetFilters">{{ t('dashboard.resetFilters') }}</NButton>
</NSpace>
</NCard>
<!-- 汇总卡片 -->
<NGrid :cols="4" :x-gap="16" :y-gap="16" style="margin-bottom: 16px;">
<NGridItem>
<NCard>
<NStatistic :label="t('dashboard.totalIncome')" :value="Number(summary.totalIncome).toFixed(2)" style="color: #18a058;">
<template #prefix>+</template>
</NStatistic>
</NCard>
</NGridItem>
<NGridItem>
<NCard>
<NStatistic :label="t('dashboard.totalExpense')" :value="Number(summary.totalExpense).toFixed(2)" style="color: #d03050;">
<template #prefix>-</template>
</NStatistic>
</NCard>
</NGridItem>
<NGridItem>
<NCard>
<NStatistic :label="t('dashboard.balance')" :value="Number(summary.balance).toFixed(2)" />
</NCard>
</NGridItem>
<NGridItem>
<NCard>
<NStatistic :label="t('dashboard.transactionCount')" :value="summary.transactionCount" />
</NCard>
</NGridItem>
</NGrid>
<!-- 概览指标 -->
<NGrid :cols="4" :x-gap="16" :y-gap="16" style="margin-bottom: 20px;">
<NGridItem>
<NCard>
<NStatistic :label="t('dashboard.avgDailyIncome')" :value="Number(summary.avgDailyIncome).toFixed(2)" />
</NCard>
</NGridItem>
<NGridItem>
<NCard>
<NStatistic :label="t('dashboard.avgDailyExpense')" :value="Number(summary.avgDailyExpense).toFixed(2)" />
</NCard>
</NGridItem>
<NGridItem>
<NCard>
<NStatistic :label="t('dashboard.maxExpense')" :value="Number(summary.maxExpenseAmount).toFixed(2)">
<template #suffix>
<span style="font-size: 12px; color: #999;">{{ (summary.maxExpenseRemark || '').slice(0, 12) }}</span>
</template>
</NStatistic>
</NCard>
</NGridItem>
<NGridItem>
<NCard>
<NStatistic :label="t('dashboard.avgExpense')" :value="Number(summary.avgExpenseAmount).toFixed(2)" />
</NCard>
</NGridItem>
</NGrid>
<!-- 趋势图 -->
<NCard style="margin-bottom: 16px;">
<template #header>
<NSpace justify="space-between" align="center">
<span>{{ t('dashboard.trend') }}</span>
<NSelect v-model:value="granularity" :options="granularityOptions" style="width: 110px;" size="small" />
</NSpace>
</template>
<VChart :option="trendOption" style="height: 350px;" autoresize />
</NCard>
<!-- 饼图区域:支出/收入分类占比 + 支出/收入支付方式占比 -->
<NGrid :cols="2" :x-gap="16" :y-gap="16">
<NGridItem>
<NCard>
<VChart :option="pieOption" style="height: 350px;" autoresize />
</NCard>
</NGridItem>
<NGridItem>
<NCard>
<VChart :option="incomePieOption" style="height: 350px;" autoresize />
</NCard>
</NGridItem>
<NGridItem>
<NCard>
<VChart :option="paymentOption" style="height: 350px;" autoresize />
</NCard>
</NGridItem>
<NGridItem>
<NCard>
<VChart :option="incomePaymentOption" style="height: 350px;" autoresize />
</NCard>
</NGridItem>
</NGrid>
</div>
</template>