Files
ledgerly/frontend/src/views/Dashboard.vue
T
rosercode 06fd80d55f feat: 登录显示加载遮罩并将统计面板图表改为异步加载
- 登录请求期间显示全屏遮罩与「正在登录」提示,并等待路由跳转完成再隐藏
- 统计面板 echarts 改为 defineAsyncComponent 按需加载,避免阻塞登录后首屏
2026-09-21 21:24:12 +08:00

949 lines
34 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, 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>