feat: 交易渠道重命名、账单来源统计默认值、AI 规则生成与统计搜索,AI 改用 openai-java SDK
- 交易记录「支付方式」全链路重命名为「交易渠道」(channel):DB 列、实体/DTO/VO、接口 /api/transaction/channels、统计维度 CHANNEL、AI 提示词与 JSON 字段、前端与中英日文案;新增 SchemaMigrator 在启动/切换数据源时幂等迁移列名(MySQL 用 information_schema、SQLite 用 PRAGMA) - 按账单来源设置「计入统计」默认值:AppConfig 持久化 + GET/PUT /api/app/bill-source-include-in-stats,导入解析按「解析器判定 && 来源默认」应用并覆盖上传与邮箱两条路径,设置页新增面板 - AI 生成规则:基于所选已打标签记录归纳分类/标签规则(POST /api/ai/rule/suggest、/api/ai/rule/apply),规则页两步弹窗(选择记录→预览创建),支持候选记录分页/搜索/筛选;按「类型+关键词+分类+标签」签名与已有规则去重,重复项标记「已存在」并禁止导入 - 规则列表新增分页与搜索:GET /api/rule/page(名称/关键词、启用状态、收支类型) - 统计面板 AI 搜索:POST /api/ai/statistics 将自然语言解析为时间/类型/分类/标签/渠道/金额/统计口径/趋势粒度并回填;交互对齐账单列表 AI 查询(弹窗 + 历史 + 示例 + 空条件守卫);统计后端补充标签筛选,仪表盘新增标签筛选与 AI 搜索 - AI 客户端改用 OpenAI 官方 Java SDK(com.openai:openai-java),自定义 baseUrl 指向 DeepSeek,保留 chat 签名与 DeepSeekResponse - AI 数据外发收敛:分类/标签建议与规则生成不再发送 rawData 与金额,仅发送类型/备注/渠道/分类/标签;新增 AiCallType.RULE / STATISTICS - 收支记录页新增「刷新」按钮
This commit is contained in:
@@ -1,8 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
// 统计面板组件:展示指定时间范围的收支汇总、概览指标、趋势、分类占比与支付方式分布
|
||||
// 统计面板组件:展示指定时间范围的收支汇总、概览指标、趋势、分类占比与交易渠道分布
|
||||
import { ref, computed, onMounted, watch, nextTick } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { NCard, NGrid, NGridItem, NStatistic, NSpace, NSelect, NDatePicker, NInput, NInputNumber, NButton } from 'naive-ui'
|
||||
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'
|
||||
import { use } from 'echarts/core'
|
||||
import { CanvasRenderer } from 'echarts/renderers'
|
||||
@@ -10,14 +11,19 @@ import { LineChart, PieChart, BarChart } from 'echarts/charts'
|
||||
import { TitleComponent, TooltipComponent, LegendComponent, GridComponent } from 'echarts/components'
|
||||
import { getStatisticsSummary, getStatisticsGroup } from '../api/statistics'
|
||||
import { getCategoryList } from '../api/category'
|
||||
import { getPaymentMethods } from '../api/transaction'
|
||||
import { getPaymentLabel } from '../utils/payment'
|
||||
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 所需组件
|
||||
use([CanvasRenderer, LineChart, PieChart, BarChart, TitleComponent, TooltipComponent, LegendComponent, GridComponent])
|
||||
|
||||
const { t, locale } = useI18n() // 国际化
|
||||
const message = useMessage() // 消息提示
|
||||
|
||||
// 时间范围快捷选项
|
||||
const rangeOptions = computed(() => [
|
||||
@@ -43,30 +49,60 @@ 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 filterPaymentMethod = ref<string[]>([]) // 支付方式(多选)
|
||||
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 paymentMethodCodes = ref<string[]>([])
|
||||
// 支付方式选项:由后端返回的编码映射为当前语言标签,locale 变化时重新生成
|
||||
const paymentOptions = computed(() => {
|
||||
// 交易渠道编码列表(从后端动态获取,含账单导入来源与人工交易渠道)
|
||||
const channelCodes = ref<string[]>([])
|
||||
// 交易渠道选项:由后端返回的编码映射为当前语言标签,locale 变化时重新生成
|
||||
const channelOptions = computed(() => {
|
||||
void locale.value
|
||||
return paymentMethodCodes.value.map((code) => ({ label: getPaymentLabel(code), value: code }))
|
||||
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
|
||||
@@ -96,7 +132,8 @@ const filterParams = computed(() => ({
|
||||
statsScope: filterStatsScope.value,
|
||||
types: filterType.value.length > 0 ? filterType.value : undefined,
|
||||
...buildCategoryFilter(),
|
||||
paymentMethods: filterPaymentMethod.value.length > 0 ? filterPaymentMethod.value : undefined,
|
||||
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,
|
||||
@@ -110,12 +147,12 @@ const summary = ref<any>({
|
||||
})
|
||||
const trendData = ref<any[]>([]) // 趋势分组数据
|
||||
const categoryData = ref<any[]>([]) // 分类分组数据
|
||||
const paymentData = ref<any[]>([]) // 支付方式分组数据
|
||||
const channelData = ref<any[]>([]) // 交易渠道分组数据
|
||||
|
||||
const pieOption = ref({}) // 支出分类饼图
|
||||
const incomePieOption = ref({}) // 收入分类饼图
|
||||
const paymentOption = ref({}) // 支出支付方式饼图
|
||||
const incomePaymentOption = ref({}) // 收入支付方式饼图
|
||||
const channelOption = ref({}) // 支出交易渠道饼图
|
||||
const incomeChannelOption = ref({}) // 收入交易渠道饼图
|
||||
const trendOption = ref({}) // 趋势柱状图
|
||||
|
||||
const pad = (n: number) => String(n).padStart(2, '0')
|
||||
@@ -220,35 +257,35 @@ function updateIncomePieChart() {
|
||||
}
|
||||
}
|
||||
|
||||
/** 更新支出支付方式占比饼图:仅统计支出,标签按当前语言本地化 */
|
||||
function updatePaymentChart() {
|
||||
paymentOption.value = {
|
||||
title: { text: t('dashboard.expensePaymentPie'), left: 'center' },
|
||||
/** 更新支出交易渠道占比饼图:仅统计支出,标签按当前语言本地化 */
|
||||
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: paymentData.value
|
||||
data: channelData.value
|
||||
.filter((p: any) => Number(p.expense) > 0)
|
||||
.map((p: any) => ({ name: getPaymentLabel(p.key), value: Number(p.expense) })),
|
||||
.map((p: any) => ({ name: getChannelLabel(p.key), value: Number(p.expense) })),
|
||||
},
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
/** 更新收入支付方式占比饼图:仅统计收入,标签按当前语言本地化 */
|
||||
function updateIncomePaymentChart() {
|
||||
incomePaymentOption.value = {
|
||||
title: { text: t('dashboard.incomePaymentPie'), left: 'center' },
|
||||
/** 更新收入交易渠道占比饼图:仅统计收入,标签按当前语言本地化 */
|
||||
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: paymentData.value
|
||||
data: channelData.value
|
||||
.filter((p: any) => Number(p.income) > 0)
|
||||
.map((p: any) => ({ name: getPaymentLabel(p.key), value: Number(p.income) })),
|
||||
.map((p: any) => ({ name: getChannelLabel(p.key), value: Number(p.income) })),
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -283,20 +320,20 @@ async function fetchData() {
|
||||
const { startTime, endTime } = range.value
|
||||
const filters = filterParams.value
|
||||
try {
|
||||
const [summaryRes, trendRes, categoryRes, paymentRes]: any[] = await Promise.all([
|
||||
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: 'paymentMethod', startTime, endTime, ...filters }),
|
||||
getStatisticsGroup({ dimension: 'channel', startTime, endTime, ...filters }),
|
||||
])
|
||||
summary.value = summaryRes.data
|
||||
trendData.value = trendRes.data
|
||||
categoryData.value = categoryRes.data
|
||||
paymentData.value = paymentRes.data
|
||||
channelData.value = channelRes.data
|
||||
updatePieChart()
|
||||
updateIncomePieChart()
|
||||
updatePaymentChart()
|
||||
updateIncomePaymentChart()
|
||||
updateChannelChart()
|
||||
updateIncomeChannelChart()
|
||||
updateTrendChart()
|
||||
} catch (e: any) {
|
||||
console.error('加载统计数据失败', e)
|
||||
@@ -319,7 +356,10 @@ function loadPrefs() {
|
||||
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
|
||||
if (Array.isArray(p.filterPaymentMethod)) filterPaymentMethod.value = p.filterPaymentMethod
|
||||
// 兼容旧版本以 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
|
||||
@@ -339,7 +379,8 @@ function savePrefs() {
|
||||
filterStatsScope: filterStatsScope.value,
|
||||
filterType: filterType.value,
|
||||
filterCategory: filterCategory.value,
|
||||
filterPaymentMethod: filterPaymentMethod.value,
|
||||
filterChannel: filterChannel.value,
|
||||
filterTag: filterTag.value,
|
||||
filterMinAmount: filterMinAmount.value,
|
||||
filterMaxAmount: filterMaxAmount.value,
|
||||
filterRemark: filterRemark.value,
|
||||
@@ -359,22 +400,149 @@ async function fetchCategories() {
|
||||
}
|
||||
}
|
||||
|
||||
/** 从后端加载支付方式列表,供筛选下拉使用 */
|
||||
async function loadPaymentMethods() {
|
||||
/** 从后端加载交易渠道列表,供筛选下拉使用 */
|
||||
async function loadChannels() {
|
||||
try {
|
||||
const res: any = await getPaymentMethods()
|
||||
paymentMethodCodes.value = res.data || []
|
||||
const res: any = await getChannels()
|
||||
channelCodes.value = res.data || []
|
||||
} 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 = []
|
||||
filterPaymentMethod.value = []
|
||||
filterChannel.value = []
|
||||
filterTag.value = []
|
||||
filterMinAmount.value = null
|
||||
filterMaxAmount.value = null
|
||||
filterRemark.value = ''
|
||||
@@ -391,20 +559,23 @@ watch(rangeType, (v) => {
|
||||
// 范围、粒度或过滤条件变化时重新加载
|
||||
watch([() => range.value.startTime, () => range.value.endTime, granularity, filterParams], fetchData)
|
||||
// 任一偏好变化时自动保存
|
||||
watch([rangeType, customRange, granularity, filterStatsScope, filterType, filterCategory, filterPaymentMethod, filterMinAmount, filterMaxAmount, filterRemark], savePrefs, { deep: true })
|
||||
watch([rangeType, customRange, granularity, filterStatsScope, filterType, filterCategory, filterChannel, filterTag, filterMinAmount, filterMaxAmount, filterRemark], savePrefs, { deep: true })
|
||||
// 语言切换时重绘图表,更新标题与图例文案
|
||||
watch(locale, () => {
|
||||
updatePieChart()
|
||||
updateIncomePieChart()
|
||||
updatePaymentChart()
|
||||
updateIncomePaymentChart()
|
||||
updateChannelChart()
|
||||
updateIncomeChannelChart()
|
||||
updateTrendChart()
|
||||
})
|
||||
|
||||
onMounted(async () => {
|
||||
loadPrefs()
|
||||
fetchCategories()
|
||||
loadPaymentMethods()
|
||||
loadChannels()
|
||||
loadTags()
|
||||
loadAiConfig()
|
||||
aiSearchHistory.load()
|
||||
await nextTick()
|
||||
suppressRangeWatch = false
|
||||
fetchData()
|
||||
@@ -426,6 +597,12 @@ onMounted(async () => {
|
||||
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>
|
||||
|
||||
@@ -459,15 +636,25 @@ onMounted(async () => {
|
||||
style="width: 200px;"
|
||||
/>
|
||||
<NSelect
|
||||
v-model:value="filterPaymentMethod"
|
||||
:options="paymentOptions"
|
||||
v-model:value="filterChannel"
|
||||
:options="channelOptions"
|
||||
multiple
|
||||
clearable
|
||||
filterable
|
||||
:max-tag-count="2"
|
||||
:placeholder="t('dashboard.filterPaymentMethod')"
|
||||
: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"
|
||||
@@ -562,7 +749,7 @@ onMounted(async () => {
|
||||
<VChart :option="trendOption" style="height: 350px;" autoresize />
|
||||
</NCard>
|
||||
|
||||
<!-- 饼图区域:支出/收入分类占比 + 支出/收入支付方式占比 -->
|
||||
<!-- 饼图区域:支出/收入分类占比 + 支出/收入交易渠道占比 -->
|
||||
<NGrid :cols="2" :x-gap="16" :y-gap="16">
|
||||
<NGridItem>
|
||||
<NCard>
|
||||
@@ -576,14 +763,57 @@ onMounted(async () => {
|
||||
</NGridItem>
|
||||
<NGridItem>
|
||||
<NCard>
|
||||
<VChart :option="paymentOption" style="height: 350px;" autoresize />
|
||||
<VChart :option="channelOption" style="height: 350px;" autoresize />
|
||||
</NCard>
|
||||
</NGridItem>
|
||||
<NGridItem>
|
||||
<NCard>
|
||||
<VChart :option="incomePaymentOption" style="height: 350px;" autoresize />
|
||||
<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>
|
||||
|
||||
Reference in New Issue
Block a user