feat: 统计面板与收支记录支持图表点击跳转与筛选双向联动
- 统计面板支出/收入分类饼图与支出/收入渠道饼图支持点击扇区跳转收支记录,并带上对应分类或渠道筛选 - 新增统计面板「查看收支记录」与收支记录「查看统计」按钮,实现两页双向跳转 - 收支记录跳转统计面板时按共享偏好还原类型、分类、渠道、标签、金额、备注、计入统计与时间范围 - 时间范围统一为具体起止区间,面板预设选择时换算为区间写入共享偏好 - 「计入统计」默认统一为「仅计入」,并修复「全部」与金额字段显式 null 的记忆 - 抽出公共跳转字段与共享筛选读写,收敛统计面板多处跳转的重复逻辑
This commit is contained in:
@@ -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: 'クリア' },
|
||||
|
||||
@@ -40,12 +40,14 @@ export function loadTransactionFilterPrefs(): Partial<TransactionFilterPrefs> {
|
||||
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
|
||||
|
||||
@@ -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<string, string | null> = {
|
||||
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<string, string | null> = {
|
||||
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;"
|
||||
/>
|
||||
<span style="color: #999; font-size: 12px;">{{ range.startTime.substring(0, 10) }} ~ {{ range.endTime.substring(0, 10) }}</span>
|
||||
<NButton @click="applyPanelNavigation">{{ t('dashboard.viewTransactions') }}</NButton>
|
||||
<NButton v-if="aiEnabled" @click="handleAiSearchOpen">
|
||||
<template #icon>
|
||||
<NIcon :component="SparklesOutline" />
|
||||
@@ -854,22 +975,22 @@ onMounted(async () => {
|
||||
<NGrid :cols="2" :x-gap="16" :y-gap="16">
|
||||
<NGridItem>
|
||||
<NCard>
|
||||
<VChart :option="pieOption" style="height: 350px;" autoresize />
|
||||
<VChart :option="pieOption" style="height: 350px;" autoresize @click="handlePieClick" />
|
||||
</NCard>
|
||||
</NGridItem>
|
||||
<NGridItem>
|
||||
<NCard>
|
||||
<VChart :option="incomePieOption" style="height: 350px;" autoresize />
|
||||
<VChart :option="incomePieOption" style="height: 350px;" autoresize @click="handlePieClick" />
|
||||
</NCard>
|
||||
</NGridItem>
|
||||
<NGridItem>
|
||||
<NCard>
|
||||
<VChart :option="channelOption" style="height: 350px;" autoresize />
|
||||
<VChart :option="channelOption" style="height: 350px;" autoresize @click="handlePieClick" />
|
||||
</NCard>
|
||||
</NGridItem>
|
||||
<NGridItem>
|
||||
<NCard>
|
||||
<VChart :option="incomeChannelOption" style="height: 350px;" autoresize />
|
||||
<VChart :option="incomeChannelOption" style="height: 350px;" autoresize @click="handlePieClick" />
|
||||
</NCard>
|
||||
</NGridItem>
|
||||
</NGrid>
|
||||
|
||||
@@ -4,7 +4,7 @@ import { ref, onMounted, onBeforeUnmount, computed, h, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useMessage, useDialog, NButton, NSpace, NDataTable, NTag, NModal, NForm, NFormItem, NInput, NInputNumber, NSelect, NDatePicker, NPagination, NCard, NSwitch, NIcon, NPopover, NAutoComplete, NAlert, NDivider, NUpload, NUploadDragger, NTabs, NTabPane } from 'naive-ui'
|
||||
import { EyeOutline, EyeOffOutline, SparklesOutline, RefreshOutline, CloudUploadOutline, CheckmarkCircleOutline } from '@vicons/ionicons5'
|
||||
import { EyeOutline, EyeOffOutline, SparklesOutline, RefreshOutline, CloudUploadOutline, CheckmarkCircleOutline, StatsChartOutline } from '@vicons/ionicons5'
|
||||
import type { DataTableColumns, UploadFileInfo } from 'naive-ui'
|
||||
import { getTransactionPage, createTransaction, updateTransaction, deleteTransaction, exportTransaction, toggleTransactionStats, toggleRemarkHidden, batchUpdateCategory, batchTags, getChannels, batchDeleteTransactions, batchCreateTransactions } from '../api/transaction'
|
||||
import { getAiConfig, suggestByAi, suggestOneByAi, applyAiSuggestions, queryByAi, parseRecordByAi, parseCategoryByAi, parseTagByAi, parseChannelByAi } from '../api/ai'
|
||||
@@ -263,7 +263,7 @@ const filterType = ref<string[]>([]) // 类型筛选(多选)
|
||||
const filterCategory = ref<number[]>([]) // 分类筛选(多选)
|
||||
const filterChannel = ref<string[]>([]) // 交易渠道筛选(多选)
|
||||
const filterDateRange = ref<[number, number] | null>(null) // 日期范围筛选(起止时间戳)
|
||||
const filterIncludeInStats = ref<string | null>(null) // 计入统计筛选:null-全部,'true'-计入,'false'-不计入
|
||||
const filterIncludeInStats = ref<string | null>('true') // 计入统计筛选:null-全部,'true'-计入,'false'-不计入(默认仅计入,与统计面板一致)
|
||||
const filterRemark = ref('') // 备注关键字搜索
|
||||
// 备注关键词历史存储键
|
||||
const REMARK_HISTORY_KEY = STORAGE_KEYS.transactionRemarkHistory
|
||||
@@ -341,10 +341,10 @@ function loadFilterPrefs() {
|
||||
if (p.filterCategory) filterCategory.value = p.filterCategory
|
||||
if (p.filterChannel) filterChannel.value = p.filterChannel
|
||||
if (p.filterDateRange) filterDateRange.value = p.filterDateRange
|
||||
if (p.filterIncludeInStats) filterIncludeInStats.value = p.filterIncludeInStats
|
||||
if (p.filterIncludeInStats !== undefined) filterIncludeInStats.value = p.filterIncludeInStats
|
||||
if (typeof p.filterRemark === 'string') filterRemark.value = p.filterRemark
|
||||
if (typeof p.filterMinAmount === 'number') filterMinAmount.value = p.filterMinAmount
|
||||
if (typeof p.filterMaxAmount === 'number') filterMaxAmount.value = p.filterMaxAmount
|
||||
if (p.filterMinAmount !== undefined) filterMinAmount.value = p.filterMinAmount
|
||||
if (p.filterMaxAmount !== undefined) filterMaxAmount.value = p.filterMaxAmount
|
||||
if (typeof p.pageSize === 'number' && pageSizes.includes(p.pageSize)) pageSize.value = p.pageSize
|
||||
if (typeof p.sortValue === 'string' && sortOptions.value.some((o) => o.value === p.sortValue)) sortValue.value = p.sortValue
|
||||
if (p.filterTag) filterTag.value = p.filterTag
|
||||
@@ -609,6 +609,17 @@ function goChannelManage() {
|
||||
router.push('/channels')
|
||||
}
|
||||
|
||||
/**
|
||||
* 携带当前筛选条件跳转到统计面板
|
||||
*
|
||||
* 先将当前筛选写入共享偏好(与统计面板共用),再带来源标记跳转,
|
||||
* 统计面板据此还原同一套筛选条件并生成对应统计信息。
|
||||
*/
|
||||
function goStatistics() {
|
||||
saveFilterPrefs()
|
||||
router.push({ path: '/dashboard', query: { from: 'transactions' } })
|
||||
}
|
||||
|
||||
/** 加载「不计入统计」记录展示样式偏好,失败时保持默认删除线 */
|
||||
async function loadExcludedDisplayStyle() {
|
||||
try {
|
||||
@@ -2018,6 +2029,12 @@ onBeforeUnmount(() => {
|
||||
<NButton @click="showImportModal = true">{{ t('transaction.importBill') }}</NButton>
|
||||
<NButton @click="goChannelManage">{{ t('channel.manageEntry') }}</NButton>
|
||||
<NButton :loading="exporting" @click="handleExport">{{ t('transaction.exportExcel') }}</NButton>
|
||||
<NButton @click="goStatistics">
|
||||
<template #icon>
|
||||
<NIcon :component="StatsChartOutline" />
|
||||
</template>
|
||||
{{ t('transaction.viewStatistics') }}
|
||||
</NButton>
|
||||
<NButton :loading="loading" @click="fetchData">
|
||||
<template #icon>
|
||||
<NIcon :component="RefreshOutline" />
|
||||
|
||||
Reference in New Issue
Block a user