- 登录请求期间显示全屏遮罩与「正在登录」提示,并等待路由跳转完成再隐藏 - 统计面板 echarts 改为 defineAsyncComponent 按需加载,避免阻塞登录后首屏
949 lines
34 KiB
Vue
949 lines
34 KiB
Vue
<script setup lang="ts">
|
||
// 统计面板组件:展示指定时间范围的收支汇总、概览指标、趋势、分类占比与交易渠道分布
|
||
import { ref, computed, onMounted, watch, nextTick, unref, defineAsyncComponent } 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 { getStatisticsSummary, getStatisticsGroup } from '../api/statistics'
|
||
import { getCategoryList } from '../api/category'
|
||
import { getTagList } from '../api/tag'
|
||
import { getChannels } from '../api/transaction'
|
||
import { getAiConfig, parseStatisticsByAi } from '../api/ai'
|
||
import type { AiStatisticsResult } from '../api/ai'
|
||
import { useKeywordHistory } from '../composables/useKeywordHistory'
|
||
import { getChannelLabel } from '../utils/channel'
|
||
import { renderCategoryLabel } from '../utils/render'
|
||
|
||
// 图表组件异步加载:echarts 体积较大,登录后进入面板时先渲染页面骨架,
|
||
// echarts 在后台按需下载并注册,避免阻塞目标页首屏
|
||
const VChart = defineAsyncComponent(async () => {
|
||
const [vueEcharts, echartsCore, renderers, charts, components] = await Promise.all([
|
||
import('vue-echarts'),
|
||
import('echarts/core'),
|
||
import('echarts/renderers'),
|
||
import('echarts/charts'),
|
||
import('echarts/components'),
|
||
])
|
||
echartsCore.use([
|
||
renderers.CanvasRenderer,
|
||
charts.LineChart,
|
||
charts.PieChart,
|
||
charts.BarChart,
|
||
components.TitleComponent,
|
||
components.TooltipComponent,
|
||
components.LegendComponent,
|
||
components.GridComponent,
|
||
])
|
||
return vueEcharts.default
|
||
})
|
||
|
||
const { t, locale } = useI18n() // 国际化
|
||
const message = useMessage() // 消息提示
|
||
const router = useRouter() // 路由跳转
|
||
|
||
// 时间范围快捷选项
|
||
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 filterChannel = ref<string[]>([]) // 交易渠道(多选)
|
||
const filterTag = ref<number[]>([]) // 标签(多选)
|
||
const filterMinAmount = ref<number | null>(null) // 最小金额
|
||
const filterMaxAmount = ref<number | null>(null) // 最大金额
|
||
const filterRemark = ref('') // 备注关键字
|
||
|
||
// AI 统计搜索状态
|
||
const aiEnabled = ref(false)
|
||
const aiQueryText = ref('')
|
||
const aiQuerying = ref(false)
|
||
const aiSearchResult = ref<AiStatisticsResult | null>(null)
|
||
const showAiSearchModal = ref(false)
|
||
|
||
// AI 搜索示例(下拉选择后填入输入框)
|
||
const aiExampleOptions = computed(() => [
|
||
{ label: t('dashboard.aiExample1'), key: t('dashboard.aiExample1') },
|
||
{ label: t('dashboard.aiExample2'), key: t('dashboard.aiExample2') },
|
||
{ label: t('dashboard.aiExample3'), key: t('dashboard.aiExample3') },
|
||
])
|
||
|
||
// AI 搜索历史记录(与账单列表一致:持久化、聚焦展示、可清空)
|
||
const aiSearchHistory = useKeywordHistory({
|
||
storageKey: 'finance.dashboard.aiSearchHistory',
|
||
text: aiQueryText,
|
||
clearLabel: () => t('ai.query.clearHistory'),
|
||
})
|
||
|
||
// 统计口径选项:仅计入 / 全部 / 仅不计入
|
||
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 channelCodes = ref<string[]>([])
|
||
// 交易渠道选项:由后端返回的编码映射为当前语言标签,locale 变化时重新生成
|
||
const channelOptions = computed(() => {
|
||
void locale.value
|
||
return channelCodes.value.map((code) => ({ label: getChannelLabel(code), value: code }))
|
||
})
|
||
|
||
/** 标签选项 */
|
||
interface TagOption {
|
||
label: string
|
||
value: number
|
||
}
|
||
const tags = ref<TagOption[]>([])
|
||
const tagOptions = computed(() => tags.value)
|
||
|
||
/** 分类选项数据结构 */
|
||
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)),
|
||
])
|
||
|
||
/** 将分类筛选拆分为分类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(),
|
||
channels: filterChannel.value.length > 0 ? filterChannel.value : undefined,
|
||
tagIds: filterTag.value.length > 0 ? filterTag.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 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({}) // 支出交易渠道饼图
|
||
const incomeChannelOption = 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 updateChannelChart() {
|
||
channelOption.value = {
|
||
title: { text: t('dashboard.expenseChannelPie'), left: 'center' },
|
||
tooltip: { trigger: 'item', formatter: '{b}: {c} ({d}%)' },
|
||
series: [
|
||
{
|
||
type: 'pie',
|
||
radius: ['40%', '70%'],
|
||
data: channelData.value
|
||
.filter((p: any) => Number(p.expense) > 0)
|
||
.map((p: any) => ({ name: getChannelLabel(p.key), value: Number(p.expense) })),
|
||
},
|
||
],
|
||
}
|
||
}
|
||
|
||
/** 更新收入交易渠道占比饼图:仅统计收入,标签按当前语言本地化 */
|
||
function updateIncomeChannelChart() {
|
||
incomeChannelOption.value = {
|
||
title: { text: t('dashboard.incomeChannelPie'), left: 'center' },
|
||
tooltip: { trigger: 'item', formatter: '{b}: {c} ({d}%)' },
|
||
series: [
|
||
{
|
||
type: 'pie',
|
||
radius: ['40%', '70%'],
|
||
data: channelData.value
|
||
.filter((p: any) => Number(p.income) > 0)
|
||
.map((p: any) => ({ name: getChannelLabel(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))
|
||
trendKeys.value = keys
|
||
|
||
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: [
|
||
{ 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
|
||
const filters = filterParams.value
|
||
try {
|
||
const [summaryRes, trendRes, categoryRes, channelRes]: any[] = await Promise.all([
|
||
getStatisticsSummary({ startTime, endTime, ...filters }),
|
||
getStatisticsGroup({ dimension: granularity.value, startTime, endTime, ...filters }),
|
||
getStatisticsGroup({ dimension: 'category', startTime, endTime, ...filters }),
|
||
getStatisticsGroup({ dimension: 'channel', startTime, endTime, ...filters }),
|
||
])
|
||
summary.value = summaryRes.data
|
||
trendData.value = trendRes.data
|
||
categoryData.value = categoryRes.data
|
||
channelData.value = channelRes.data
|
||
updatePieChart()
|
||
updateIncomePieChart()
|
||
updateChannelChart()
|
||
updateIncomeChannelChart()
|
||
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
|
||
// 兼容旧版本以 filterPaymentMethod 存储的偏好
|
||
const savedChannel = Array.isArray(p.filterChannel) ? p.filterChannel : p.filterPaymentMethod
|
||
if (Array.isArray(savedChannel)) filterChannel.value = savedChannel
|
||
if (Array.isArray(p.filterTag)) filterTag.value = p.filterTag
|
||
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,
|
||
filterChannel: filterChannel.value,
|
||
filterTag: filterTag.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 loadChannels() {
|
||
try {
|
||
const res: any = await getChannels()
|
||
channelCodes.value = (res.data || []).map((item: any) => item.code)
|
||
} catch (e) {
|
||
// 静默处理,交易渠道下拉保持为空
|
||
}
|
||
}
|
||
|
||
/** 加载标签选项,供筛选下拉与 AI 统计使用 */
|
||
async function loadTags() {
|
||
try {
|
||
const res: any = await getTagList()
|
||
tags.value = (res.data || []).map((tag: any) => ({ label: tag.name, value: tag.id }))
|
||
} catch (e) {
|
||
// 静默处理
|
||
}
|
||
}
|
||
|
||
/** 读取 AI 启用状态,未启用时不渲染 AI 搜索框 */
|
||
async function loadAiConfig() {
|
||
try {
|
||
const res: any = await getAiConfig()
|
||
aiEnabled.value = !!res.data?.enabled
|
||
} catch (e) {
|
||
aiEnabled.value = false
|
||
}
|
||
}
|
||
|
||
/** 将 AI 解析结果应用到统计面板筛选条件与粒度 */
|
||
async function applyAiStatistics(result: AiStatisticsResult) {
|
||
if (result.startTime && result.endTime) {
|
||
suppressRangeWatch = true
|
||
rangeType.value = 'custom'
|
||
customRange.value = [
|
||
new Date(result.startTime.replace('T', ' ').replace(/-/g, '/')).getTime(),
|
||
new Date(result.endTime.replace('T', ' ').replace(/-/g, '/')).getTime(),
|
||
]
|
||
}
|
||
if (result.dimension) {
|
||
granularity.value = result.dimension
|
||
}
|
||
if (result.statsScope) {
|
||
filterStatsScope.value = result.statsScope
|
||
}
|
||
filterType.value = result.types || []
|
||
filterCategory.value = result.categoryIds || []
|
||
filterChannel.value = result.channels || []
|
||
filterTag.value = result.tagIds || []
|
||
filterMinAmount.value = result.minAmount
|
||
filterMaxAmount.value = result.maxAmount
|
||
filterRemark.value = result.remark || ''
|
||
await nextTick()
|
||
suppressRangeWatch = false
|
||
}
|
||
|
||
/** 解析结果是否为空(无任何可用条件) */
|
||
const aiSearchEmpty = computed(() => {
|
||
const r = aiSearchResult.value
|
||
if (!r) return true
|
||
return (r.types?.length ?? 0) === 0
|
||
&& (r.categoryIds?.length ?? 0) === 0
|
||
&& (r.tagIds?.length ?? 0) === 0
|
||
&& (r.channels?.length ?? 0) === 0
|
||
&& !r.startTime
|
||
&& r.minAmount == null
|
||
&& r.maxAmount == null
|
||
&& !r.remark
|
||
&& !r.statsScope
|
||
&& !r.dimension
|
||
})
|
||
|
||
/** 打开 AI 搜索弹窗 */
|
||
function handleAiSearchOpen() {
|
||
if (!aiEnabled.value) {
|
||
message.warning(t('ai.needConfig'))
|
||
return
|
||
}
|
||
showAiSearchModal.value = true
|
||
}
|
||
|
||
/** 提交自然语言统计搜索 */
|
||
async function handleAiStatistics() {
|
||
if (!aiEnabled.value) {
|
||
message.warning(t('ai.needConfig'))
|
||
return
|
||
}
|
||
const text = aiQueryText.value.trim()
|
||
if (!text) {
|
||
message.warning(t('ai.query.emptyInput'))
|
||
return
|
||
}
|
||
aiSearchHistory.record()
|
||
aiQuerying.value = true
|
||
try {
|
||
const res: any = await parseStatisticsByAi(text)
|
||
aiSearchResult.value = res.data
|
||
// 无任何可用条件时提示,避免误清空当前筛选与时间范围
|
||
if (aiSearchEmpty.value) {
|
||
message.warning(t('ai.query.noCondition'))
|
||
return
|
||
}
|
||
await applyAiStatistics(aiSearchResult.value as AiStatisticsResult)
|
||
showAiSearchModal.value = false
|
||
message.success(aiSearchResult.value?.summary || t('dashboard.aiSearchApplied'))
|
||
} catch (e: any) {
|
||
message.error(e.message || t('dashboard.aiSearchFailed'))
|
||
} finally {
|
||
aiQuerying.value = false
|
||
}
|
||
}
|
||
|
||
/** 选中历史项:清空历史,或填入并立即查询 */
|
||
function handleAiSearchSelect(value: string) {
|
||
if (value === aiSearchHistory.clearValue) {
|
||
aiSearchHistory.clear()
|
||
return
|
||
}
|
||
aiQueryText.value = value
|
||
handleAiStatistics()
|
||
}
|
||
|
||
/** 回车时发起查询(阻止 NAutoComplete 内部选中建议项) */
|
||
function handleAiSearchKeydown(e: KeyboardEvent) {
|
||
if (e.key === 'Enter') {
|
||
e.stopPropagation()
|
||
handleAiStatistics()
|
||
}
|
||
}
|
||
|
||
/** 选择示例后填入输入框 */
|
||
function handleExampleSelect(key: string) {
|
||
aiQueryText.value = key
|
||
}
|
||
|
||
/** 重置所有过滤条件(保留时间范围与粒度) */
|
||
function handleResetFilters() {
|
||
filterStatsScope.value = 'INCLUDED'
|
||
filterType.value = []
|
||
filterCategory.value = []
|
||
filterChannel.value = []
|
||
filterTag.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, filterChannel, filterTag, filterMinAmount, filterMaxAmount, filterRemark], savePrefs, { deep: true })
|
||
// 语言切换时重绘图表,更新标题与图例文案
|
||
watch(locale, () => {
|
||
updatePieChart()
|
||
updateIncomePieChart()
|
||
updateChannelChart()
|
||
updateIncomeChannelChart()
|
||
updateTrendChart()
|
||
})
|
||
|
||
onMounted(async () => {
|
||
loadPrefs()
|
||
fetchCategories()
|
||
loadChannels()
|
||
loadTags()
|
||
loadAiConfig()
|
||
aiSearchHistory.load()
|
||
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>
|
||
<NButton v-if="aiEnabled" @click="handleAiSearchOpen">
|
||
<template #icon>
|
||
<NIcon :component="SparklesOutline" />
|
||
</template>
|
||
{{ t('ai.query.entry') }}
|
||
</NButton>
|
||
</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="filterChannel"
|
||
:options="channelOptions"
|
||
multiple
|
||
clearable
|
||
filterable
|
||
:max-tag-count="2"
|
||
:placeholder="t('dashboard.filterChannel')"
|
||
style="width: 200px;"
|
||
/>
|
||
<NSelect
|
||
v-model:value="filterTag"
|
||
:options="tagOptions"
|
||
multiple
|
||
clearable
|
||
filterable
|
||
:max-tag-count="2"
|
||
:placeholder="t('dashboard.filterTag')"
|
||
style="width: 180px;"
|
||
/>
|
||
<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 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 ref="trendChartRef" :option="trendOption" style="height: 350px;" autoresize @click="handleTrendClick" @zr:click="handleTrendBlankClick" />
|
||
</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="channelOption" style="height: 350px;" autoresize />
|
||
</NCard>
|
||
</NGridItem>
|
||
<NGridItem>
|
||
<NCard>
|
||
<VChart :option="incomeChannelOption" style="height: 350px;" autoresize />
|
||
</NCard>
|
||
</NGridItem>
|
||
</NGrid>
|
||
|
||
<!-- AI 统计搜索弹窗(交互与账单列表 AI 查询一致) -->
|
||
<NModal
|
||
v-if="aiEnabled"
|
||
v-model:show="showAiSearchModal"
|
||
preset="card"
|
||
:title="t('ai.query.title')"
|
||
style="width: 520px; max-width: 92vw;"
|
||
>
|
||
<NSpace vertical :size="12">
|
||
<NAlert type="info" :show-icon="true">{{ t('ai.query.hint') }}</NAlert>
|
||
<NAutoComplete
|
||
:value="aiQueryText"
|
||
:options="aiSearchHistory.options.value"
|
||
:get-show="aiSearchHistory.showMenu"
|
||
:render-label="aiSearchHistory.renderLabel"
|
||
:input-props="{ onKeydown: handleAiSearchKeydown }"
|
||
clearable
|
||
:placeholder="t('dashboard.aiSearchPlaceholder')"
|
||
style="width: 100%;"
|
||
@update:value="aiSearchHistory.handleInput"
|
||
@select="handleAiSearchSelect"
|
||
>
|
||
<template #prefix>
|
||
<NIcon :component="SparklesOutline" />
|
||
</template>
|
||
</NAutoComplete>
|
||
<NSpace justify="space-between" align="center">
|
||
<NDropdown trigger="click" :options="aiExampleOptions" @select="handleExampleSelect">
|
||
<NButton text type="primary" size="small">
|
||
<template #icon>
|
||
<NIcon :component="BulbOutline" />
|
||
</template>
|
||
{{ t('transaction.exampleLabel') }}
|
||
</NButton>
|
||
</NDropdown>
|
||
<NSpace>
|
||
<NButton @click="showAiSearchModal = false">{{ t('common.cancel') }}</NButton>
|
||
<NButton type="primary" :loading="aiQuerying" @click="handleAiStatistics">{{ t('ai.query.button') }}</NButton>
|
||
</NSpace>
|
||
</NSpace>
|
||
</NSpace>
|
||
</NModal>
|
||
</div>
|
||
</template>
|