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:
2026-09-20 17:13:24 +08:00
parent 0a8811534b
commit 2976b85967
58 changed files with 3347 additions and 345 deletions
+280 -50
View File
@@ -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>