feat: 用户级偏好配置与外观设置,枚举统一抽取与账单类型后端下发
- 新增用户偏好表 t_user_preference 与进程内缓存,账单来源统计默认值由全局改为按用户存储(/api/setting) - 新增外观设置:不计入统计记录支持删除线与灰色标签两种展示样式 - 抽取 ChannelType / TransactionType / StatisticsScope 枚举,替换各处硬编码的渠道、类型与统计口径 - BillType 内聚各来源扩展名,新增 /transaction/import/types 接口,前端改为按后端下发渲染 - 仪表盘趋势图支持点击柱子跳转收支记录并带入筛选条件 - 布局内容区按路由记忆滚动位置;已登录访问登录/注册页自动跳转
This commit is contained in:
@@ -1,7 +1,8 @@
|
||||
<script setup lang="ts">
|
||||
// 统计面板组件:展示指定时间范围的收支汇总、概览指标、趋势、分类占比与交易渠道分布
|
||||
import { ref, computed, onMounted, watch, nextTick } from 'vue'
|
||||
import { ref, computed, onMounted, watch, nextTick, unref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { NCard, NGrid, NGridItem, NStatistic, NSpace, NSelect, NDatePicker, NInput, NInputNumber, NButton, NAutoComplete, NModal, NAlert, NDropdown, NIcon, useMessage } from 'naive-ui'
|
||||
import { SparklesOutline, BulbOutline } from '@vicons/ionicons5'
|
||||
import VChart from 'vue-echarts'
|
||||
@@ -24,6 +25,7 @@ use([CanvasRenderer, LineChart, PieChart, BarChart, TitleComponent, TooltipCompo
|
||||
|
||||
const { t, locale } = useI18n() // 国际化
|
||||
const message = useMessage() // 消息提示
|
||||
const router = useRouter() // 路由跳转
|
||||
|
||||
// 时间范围快捷选项
|
||||
const rangeOptions = computed(() => [
|
||||
@@ -146,9 +148,11 @@ const summary = ref<any>({
|
||||
maxExpenseAmount: 0, maxExpenseTime: null, maxExpenseRemark: '', avgExpenseAmount: 0,
|
||||
})
|
||||
const trendData = ref<any[]>([]) // 趋势分组数据
|
||||
const trendKeys = ref<string[]>([]) // 趋势图当前时间桶键序列(与柱状图 x 轴一一对应)
|
||||
const categoryData = ref<any[]>([]) // 分类分组数据
|
||||
const channelData = ref<any[]>([]) // 交易渠道分组数据
|
||||
|
||||
const trendChartRef = ref<any>(null) // 趋势图组件引用(用于取 ECharts 实例做像素换算)
|
||||
const pieOption = ref({}) // 支出分类饼图
|
||||
const incomePieOption = ref({}) // 收入分类饼图
|
||||
const channelOption = ref({}) // 支出交易渠道饼图
|
||||
@@ -301,6 +305,7 @@ function updateTrendChart() {
|
||||
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))
|
||||
trendKeys.value = keys
|
||||
|
||||
trendOption.value = {
|
||||
tooltip: { trigger: 'axis' },
|
||||
@@ -309,12 +314,118 @@ function updateTrendChart() {
|
||||
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' },
|
||||
{ id: 'INCOME', name: t('common.types.income'), type: 'bar', data: income, color: '#18a058' },
|
||||
{ id: 'EXPENSE', name: t('common.types.expense'), type: 'bar', data: expense, color: '#d03050' },
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
/** 将趋势分组的时间桶键换算为本地时间戳区间 [start, end],用于跳转收支记录页的日期筛选 */
|
||||
function bucketRange(key: string, gran: string): [number, number] {
|
||||
const startOfDay = (y: number, m: number, d: number) => new Date(y, m, d, 0, 0, 0).getTime()
|
||||
const endOfDay = (y: number, m: number, d: number) => new Date(y, m, d, 23, 59, 59).getTime()
|
||||
if (gran === 'day') {
|
||||
const [y, m, d] = key.split('-').map(Number)
|
||||
return [startOfDay(y, m - 1, d), endOfDay(y, m - 1, d)]
|
||||
}
|
||||
if (gran === 'week') {
|
||||
const [y, w] = key.split('-W').map(Number)
|
||||
// ISO 周:含 1 月 4 日的那一周为第 1 周,周一为起始
|
||||
const jan4 = new Date(Date.UTC(y, 0, 4))
|
||||
const jan4Day = jan4.getUTCDay() || 7
|
||||
const week1Monday = new Date(Date.UTC(y, 0, 4 - jan4Day + 1))
|
||||
const monday = new Date(week1Monday.getTime() + (w - 1) * 7 * 86400000)
|
||||
const sunday = new Date(monday.getTime() + 6 * 86400000)
|
||||
return [
|
||||
startOfDay(monday.getUTCFullYear(), monday.getUTCMonth(), monday.getUTCDate()),
|
||||
endOfDay(sunday.getUTCFullYear(), sunday.getUTCMonth(), sunday.getUTCDate()),
|
||||
]
|
||||
}
|
||||
if (gran === 'month') {
|
||||
const [y, m] = key.split('-').map(Number)
|
||||
return [startOfDay(y, m - 1, 1), endOfDay(y, m, 0)]
|
||||
}
|
||||
if (gran === 'quarter') {
|
||||
const [y, q] = key.split('-Q').map(Number)
|
||||
const startMonth = (q - 1) * 3
|
||||
return [startOfDay(y, startMonth, 1), endOfDay(y, startMonth + 3, 0)]
|
||||
}
|
||||
const y = Number(key)
|
||||
return [startOfDay(y, 0, 1), endOfDay(y, 11, 31)]
|
||||
}
|
||||
|
||||
/** 收支记录页筛选偏好存储键(与 TransactionList 保持一致) */
|
||||
const TRANSACTION_FILTER_PREFS_KEY = 'finance.transaction.filterPrefs'
|
||||
|
||||
/**
|
||||
* 将趋势时间桶与收支类型写入收支记录页筛选偏好,并跳转到收支记录
|
||||
*
|
||||
* :param key: 趋势时间桶键
|
||||
* :param type: 收支类型;为空表示不限定类型(沿用面板当前类型筛选)
|
||||
*/
|
||||
function applyTrendNavigation(key: string, type: string | null) {
|
||||
// 统计口径映射为收支记录页的计入统计三态
|
||||
const statsScopeMap: Record<string, string | null> = {
|
||||
INCLUDED: 'true',
|
||||
EXCLUDED: 'false',
|
||||
ALL: null,
|
||||
}
|
||||
try {
|
||||
// 合并写入偏好,保留分页、排序等与本跳转无关的既有设置
|
||||
const raw = localStorage.getItem(TRANSACTION_FILTER_PREFS_KEY)
|
||||
const existing = raw ? JSON.parse(raw) : {}
|
||||
localStorage.setItem(TRANSACTION_FILTER_PREFS_KEY, JSON.stringify({
|
||||
...existing,
|
||||
filterType: type ? [type] : filterType.value,
|
||||
filterCategory: filterCategory.value,
|
||||
filterChannel: filterChannel.value,
|
||||
filterTag: filterTag.value,
|
||||
filterDateRange: bucketRange(key, granularity.value),
|
||||
filterIncludeInStats: statsScopeMap[filterStatsScope.value] ?? null,
|
||||
filterMinAmount: filterMinAmount.value,
|
||||
filterMaxAmount: filterMaxAmount.value,
|
||||
filterRemark: filterRemark.value.trim(),
|
||||
}))
|
||||
} catch (e) {
|
||||
// 忽略写入失败,仍继续跳转
|
||||
}
|
||||
router.push('/transactions')
|
||||
}
|
||||
|
||||
/**
|
||||
* 命中趋势柱子的点击:按该柱子的时间桶与收支类型跳转收支记录页
|
||||
*
|
||||
* :param params: ECharts 元素点击事件参数(含 seriesId、dataIndex)
|
||||
*/
|
||||
function handleTrendClick(params: any) {
|
||||
if (params?.componentType !== 'series' || params.dataIndex == null) return
|
||||
const key = trendKeys.value[params.dataIndex]
|
||||
if (!key) return
|
||||
const type = params.seriesId === 'INCOME' ? 'INCOME' : 'EXPENSE'
|
||||
applyTrendNavigation(key, type)
|
||||
}
|
||||
|
||||
/**
|
||||
* 点击趋势图空白处(含柱顶上方):整列可点,按所在时间桶跳转,不限定收支类型(沿用面板当前类型筛选)
|
||||
*
|
||||
* :param e: zrender 点击事件(含相对画布的 offsetX/offsetY)
|
||||
*/
|
||||
function handleTrendBlankClick(e: any) {
|
||||
// 命中柱子等图形时交给 handleTrendClick 处理,避免重复跳转
|
||||
if (e?.target) return
|
||||
const chart = unref(trendChartRef.value?.chart)
|
||||
if (!chart || e?.offsetX == null || e?.offsetY == null) return
|
||||
const point: [number, number] = [e.offsetX, e.offsetY]
|
||||
// 仅处理绘图区内的点击,忽略标题、图例、坐标轴等区域
|
||||
if (!chart.containPixel({ gridIndex: 0 }, point)) return
|
||||
// 反推点击位置所在的时间桶(类目轴返回类目索引)
|
||||
const coord = chart.convertFromPixel({ gridIndex: 0 }, point)
|
||||
if (!Array.isArray(coord)) return
|
||||
const key = trendKeys.value[Math.round(coord[0])]
|
||||
if (!key) return
|
||||
applyTrendNavigation(key, null)
|
||||
}
|
||||
|
||||
/** 并行请求所有统计数据并更新图表 */
|
||||
async function fetchData() {
|
||||
const { startTime, endTime } = range.value
|
||||
@@ -742,11 +853,14 @@ onMounted(async () => {
|
||||
<NCard style="margin-bottom: 16px;">
|
||||
<template #header>
|
||||
<NSpace justify="space-between" align="center">
|
||||
<span>{{ t('dashboard.trend') }}</span>
|
||||
<span>
|
||||
{{ t('dashboard.trend') }}
|
||||
<span style="color: #999; font-size: 12px; margin-left: 8px;">{{ t('dashboard.trendClickHint') }}</span>
|
||||
</span>
|
||||
<NSelect v-model:value="granularity" :options="granularityOptions" style="width: 110px;" size="small" />
|
||||
</NSpace>
|
||||
</template>
|
||||
<VChart :option="trendOption" style="height: 350px;" autoresize />
|
||||
<VChart ref="trendChartRef" :option="trendOption" style="height: 350px;" autoresize @click="handleTrendClick" @zr:click="handleTrendBlankClick" />
|
||||
</NCard>
|
||||
|
||||
<!-- 饼图区域:支出/收入分类占比 + 支出/收入交易渠道占比 -->
|
||||
|
||||
Reference in New Issue
Block a user