diff --git a/frontend/src/locales/messages.ts b/frontend/src/locales/messages.ts index 16027f0..57d4793 100644 --- a/frontend/src/locales/messages.ts +++ b/frontend/src/locales/messages.ts @@ -384,6 +384,7 @@ export default { expenseChannelPie: { zh: '支出交易渠道占比', en: 'Expense by Channel', ja: '支出の取引チャネル割合' }, incomeChannelPie: { zh: '收入交易渠道占比', en: 'Income by Channel', ja: '収入の取引チャネル割合' }, uncategorized: { zh: '未分类', en: 'Uncategorized', ja: '未分類' }, + viewTransactions: { zh: '查看收支记录', en: 'View Transactions', ja: '収支記録を表示' }, }, // 收支记录 @@ -401,6 +402,7 @@ export default { exitBatchMode: { zh: '退出批量', en: 'Exit Batch', ja: '一括終了' }, importBill: { zh: '导入账单', en: 'Import Bill', ja: '明細をインポート' }, exportExcel: { zh: '导出 Excel', en: 'Export Excel', ja: 'Excel をエクスポート' }, + viewStatistics: { zh: '查看统计', en: 'View Statistics', ja: '統計を表示' }, addRecord: { zh: '新增记录', en: 'Add Record', ja: '記録を追加' }, moreFilter: { zh: '更多筛选', en: 'More Filters', ja: '詳細フィルター' }, clear: { zh: '清空', en: 'Clear', ja: 'クリア' }, diff --git a/frontend/src/utils/transactionFilterPrefs.ts b/frontend/src/utils/transactionFilterPrefs.ts index 62212f0..4991bdd 100644 --- a/frontend/src/utils/transactionFilterPrefs.ts +++ b/frontend/src/utils/transactionFilterPrefs.ts @@ -40,12 +40,14 @@ export function loadTransactionFilterPrefs(): Partial { if (Array.isArray(raw.filterDateRange) && raw.filterDateRange.length === 2) { result.filterDateRange = raw.filterDateRange as [number, number] } - if (raw.filterIncludeInStats === 'true' || raw.filterIncludeInStats === 'false') { + // filterIncludeInStats 为三态:'true'-计入、'false'-不计入、null-全部;显式 null 也需保留 + if (raw.filterIncludeInStats === 'true' || raw.filterIncludeInStats === 'false' || raw.filterIncludeInStats === null) { result.filterIncludeInStats = raw.filterIncludeInStats } if (typeof raw.filterRemark === 'string') result.filterRemark = raw.filterRemark - if (typeof raw.filterMinAmount === 'number') result.filterMinAmount = raw.filterMinAmount - if (typeof raw.filterMaxAmount === 'number') result.filterMaxAmount = raw.filterMaxAmount + // 金额支持显式 null(表示已清空),需与「键不存在」区分 + if (typeof raw.filterMinAmount === 'number' || raw.filterMinAmount === null) result.filterMinAmount = raw.filterMinAmount + if (typeof raw.filterMaxAmount === 'number' || raw.filterMaxAmount === null) result.filterMaxAmount = raw.filterMaxAmount if (typeof raw.pageSize === 'number') result.pageSize = raw.pageSize if (typeof raw.sortValue === 'string') result.sortValue = raw.sortValue if (Array.isArray(raw.filterTag)) result.filterTag = raw.filterTag diff --git a/frontend/src/views/Dashboard.vue b/frontend/src/views/Dashboard.vue index 0bb97a2..28d6d91 100644 --- a/frontend/src/views/Dashboard.vue +++ b/frontend/src/views/Dashboard.vue @@ -2,7 +2,7 @@ // 统计面板组件:展示指定时间范围的收支汇总、概览指标、趋势、分类占比与交易渠道分布 import { ref, computed, onMounted, watch, nextTick, unref, defineAsyncComponent } from 'vue' import { useI18n } from 'vue-i18n' -import { useRouter } from 'vue-router' +import { useRouter, useRoute } from 'vue-router' import { NCard, NGrid, NGridItem, NStatistic, NSpace, NSelect, NDatePicker, NInput, NInputNumber, NButton, NIcon, useMessage } from 'naive-ui' import { SparklesOutline } from '@vicons/ionicons5' import { getStatisticsSummary, getStatisticsGroup } from '../api/statistics' @@ -15,7 +15,7 @@ import { getChannelLabel } from '../utils/channel' import { renderCategoryLabel } from '../utils/render' import { readJson, writeJson } from '../utils/storage' import { STORAGE_KEYS } from '../utils/storageKeys' -import { saveTransactionFilterPrefs } from '../utils/transactionFilterPrefs' +import { loadTransactionFilterPrefs, saveTransactionFilterPrefs } from '../utils/transactionFilterPrefs' import AiQueryWindow from '../components/AiQueryWindow.vue' // 图表组件异步加载:echarts 体积较大,登录后进入面板时先渲染页面骨架, @@ -44,6 +44,7 @@ const VChart = defineAsyncComponent(async () => { const { t, locale } = useI18n() // 国际化 const message = useMessage() // 消息提示 const router = useRouter() // 路由跳转 +const route = useRoute() // 当前路由(用于识别来自收支记录页的跳转) // 时间范围快捷选项 const rangeOptions = computed(() => [ @@ -259,9 +260,10 @@ function updatePieChart() { { type: 'pie', radius: ['40%', '70%'], + cursor: 'pointer', data: categoryData.value .filter((c: any) => c.type === 'EXPENSE') - .map((c: any) => ({ name: c.name, value: Number(c.total) })), + .map((c: any) => ({ name: c.name, value: Number(c.total), categoryId: c.key == null ? 0 : Number(c.key), navType: 'EXPENSE' })), }, ], } @@ -276,9 +278,10 @@ function updateIncomePieChart() { { type: 'pie', radius: ['40%', '70%'], + cursor: 'pointer', data: categoryData.value .filter((c: any) => c.type === 'INCOME') - .map((c: any) => ({ name: c.name, value: Number(c.total) })), + .map((c: any) => ({ name: c.name, value: Number(c.total), categoryId: c.key == null ? 0 : Number(c.key), navType: 'INCOME' })), }, ], } @@ -293,9 +296,10 @@ function updateChannelChart() { { type: 'pie', radius: ['40%', '70%'], + cursor: 'pointer', data: channelData.value .filter((p: any) => Number(p.expense) > 0) - .map((p: any) => ({ name: getChannelLabel(p.key), value: Number(p.expense) })), + .map((p: any) => ({ name: getChannelLabel(p.key), value: Number(p.expense), channelCode: p.key, navType: 'EXPENSE' })), }, ], } @@ -310,9 +314,10 @@ function updateIncomeChannelChart() { { type: 'pie', radius: ['40%', '70%'], + cursor: 'pointer', data: channelData.value .filter((p: any) => Number(p.income) > 0) - .map((p: any) => ({ name: getChannelLabel(p.key), value: Number(p.income) })), + .map((p: any) => ({ name: getChannelLabel(p.key), value: Number(p.income), channelCode: p.key, navType: 'INCOME' })), }, ], } @@ -377,6 +382,33 @@ function bucketRange(key: string, gran: string): [number, number] { return [startOfDay(y, 0, 1), endOfDay(y, 11, 31)] } +/** 统计口径映射为收支记录页的计入统计三态 */ +const STATS_SCOPE_MAP: Record = { + INCLUDED: 'true', + EXCLUDED: 'false', + ALL: null, +} + +/** 跳转收支记录页的公共筛选字段:统计口径、标签、金额、备注 */ +function commonNavigationPatch() { + return { + filterTag: filterTag.value, + filterIncludeInStats: STATS_SCOPE_MAP[filterStatsScope.value] ?? null, + filterMinAmount: filterMinAmount.value, + filterMaxAmount: filterMaxAmount.value, + filterRemark: filterRemark.value.trim(), + } +} + +/** 将当前面板时间范围换算为收支记录页的日期筛选区间 */ +function currentDateRange(): [number, number] { + const { startTime, endTime } = range.value + return [ + new Date(startTime.replace('T', ' ').replace(/-/g, '/')).getTime(), + new Date(endTime.replace('T', ' ').replace(/-/g, '/')).getTime(), + ] +} + /** * 将趋势时间桶与收支类型写入收支记录页筛选偏好,并跳转到收支记录 * @@ -384,27 +416,83 @@ function bucketRange(key: string, gran: string): [number, number] { * :param type: 收支类型;为空表示不限定类型(沿用面板当前类型筛选) */ function applyTrendNavigation(key: string, type: string | null) { - // 统计口径映射为收支记录页的计入统计三态 - const statsScopeMap: Record = { - INCLUDED: 'true', - EXCLUDED: 'false', - ALL: null, - } // 合并写入偏好,保留分页、排序等与本跳转无关的既有设置 saveTransactionFilterPrefs({ 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(), + ...commonNavigationPatch(), }) router.push('/transactions') } +/** + * 点击分类饼图扇区:按该分类与饼图收支类型跳转收支记录页,并清空渠道筛选 + * + * :param type: 收支类型(饼图所属类型) + * :param categoryId: 分类ID,0 表示未分类 + */ +function applyCategoryNavigation(type: string, categoryId: number) { + saveTransactionFilterPrefs({ + filterType: [type], + filterCategory: [categoryId], + filterChannel: [], + filterDateRange: currentDateRange(), + ...commonNavigationPatch(), + }) + router.push('/transactions') +} + +/** + * 点击渠道饼图扇区:按该渠道与饼图收支类型跳转收支记录页,并清空分类筛选 + * + * :param type: 收支类型(饼图所属类型) + * :param channelCode: 交易渠道编码 + */ +function applyChannelNavigation(type: string, channelCode: string) { + saveTransactionFilterPrefs({ + filterType: [type], + filterChannel: [channelCode], + filterCategory: [], + filterDateRange: currentDateRange(), + ...commonNavigationPatch(), + }) + router.push('/transactions') +} + +/** 携带统计面板当前全部筛选条件跳转到收支记录页 */ +function applyPanelNavigation() { + saveTransactionFilterPrefs({ + filterType: filterType.value, + filterCategory: filterCategory.value, + filterChannel: filterChannel.value, + filterDateRange: currentDateRange(), + ...commonNavigationPatch(), + }) + router.push('/transactions') +} + +/** + * 处理分类/渠道饼图扇区点击:分类按分类ID跳转,渠道按渠道编码跳转,无渠道编码的扇区不响应 + * + * :param params: ECharts 元素点击事件参数 + */ +function handlePieClick(params: any) { + if (params?.componentType !== 'series') return + const d = params?.data + if (!d) return + // 渠道饼图数据项带 channelCode 字段,为空表示未指定渠道,不响应 + if ('channelCode' in d) { + if (!d.channelCode) return + applyChannelNavigation(d.navType, d.channelCode) + return + } + if (d.categoryId != null) { + applyCategoryNavigation(d.navType, Number(d.categoryId)) + } +} + /** * 命中趋势柱子的点击:按该柱子的时间桶与收支类型跳转收支记录页 * @@ -490,6 +578,33 @@ function loadPrefs() { if (['day', 'week', 'month', 'quarter', 'year'].includes(p.granularity)) granularity.value = p.granularity } +/** + * 将收支记录页共享的筛选偏好应用到统计面板(仅从收支记录跳转进入时调用) + * + * 时间范围统一为具体起止区间:存在日期区间时切换为自定义范围。 + */ +function applySharedFilterPrefs() { + const p = loadTransactionFilterPrefs() + if (p.filterType) filterType.value = p.filterType + if (p.filterCategory) filterCategory.value = p.filterCategory + if (p.filterChannel) filterChannel.value = p.filterChannel + if (p.filterTag) filterTag.value = p.filterTag + if (p.filterMinAmount !== undefined) filterMinAmount.value = p.filterMinAmount + if (p.filterMaxAmount !== undefined) filterMaxAmount.value = p.filterMaxAmount + if (p.filterRemark !== undefined) filterRemark.value = p.filterRemark + // 计入统计三态反向映射为面板统计口径 + if (p.filterIncludeInStats !== undefined) { + filterStatsScope.value = p.filterIncludeInStats === 'true' + ? 'INCLUDED' + : p.filterIncludeInStats === 'false' ? 'EXCLUDED' : 'ALL' + } + // 日期区间统一映射为自定义时间范围 + if (p.filterDateRange) { + rangeType.value = 'custom' + customRange.value = p.filterDateRange + } +} + /** 保存面板偏好,下次进入自动还原 */ function savePrefs() { writeJson(DASHBOARD_PREFS_KEY, { @@ -674,6 +789,11 @@ onMounted(async () => { loadChannels() loadTags() loadAiConfig() + // 从收支记录页跳转进入时,用共享筛选偏好覆盖面板筛选,并清除来源标记 + if (route.query.from === 'transactions') { + applySharedFilterPrefs() + router.replace({ path: '/dashboard' }) + } await nextTick() suppressRangeWatch = false fetchData() @@ -695,6 +815,7 @@ onMounted(async () => { style="width: 260px;" /> {{ range.startTime.substring(0, 10) }} ~ {{ range.endTime.substring(0, 10) }} + {{ t('dashboard.viewTransactions') }}