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
+52 -44
View File
@@ -3,14 +3,14 @@
import { ref, onMounted, computed, h, watch } from 'vue'
import { useI18n } from 'vue-i18n'
import { useMessage, useDialog, NButton, NSpace, NDataTable, NTag, NModal, NForm, NFormItem, NInput, NInputNumber, NSelect, NDatePicker, NPagination, NCard, NSwitch, NIcon, NPopover, NAutoComplete, NAlert, NDivider, NDropdown } from 'naive-ui'
import { EyeOutline, EyeOffOutline, SparklesOutline, BulbOutline } from '@vicons/ionicons5'
import { EyeOutline, EyeOffOutline, SparklesOutline, BulbOutline, RefreshOutline } from '@vicons/ionicons5'
import type { DataTableColumns } from 'naive-ui'
import { getTransactionPage, createTransaction, updateTransaction, deleteTransaction, exportTransaction, toggleTransactionStats, toggleRemarkHidden, batchUpdateCategory, batchTags, getPaymentMethods, batchDeleteTransactions } from '../api/transaction'
import { getTransactionPage, createTransaction, updateTransaction, deleteTransaction, exportTransaction, toggleTransactionStats, toggleRemarkHidden, batchUpdateCategory, batchTags, getChannels, batchDeleteTransactions } from '../api/transaction'
import { getAiConfig, suggestByAi, suggestOneByAi, applyAiSuggestions, queryByAi, parseRecordByAi, parseCategoryByAi, parseTagByAi } from '../api/ai'
import type { AiSuggestion, AiQueryResult, AiRecordResult } from '../api/ai'
import { getCategoryList, createCategory } from '../api/category'
import { getTagList, createTag } from '../api/tag'
import { getPaymentLabel } from '../utils/payment'
import { getChannelLabel } from '../utils/channel'
import { renderCategoryLabel } from '../utils/render'
import { useKeywordHistory } from '../composables/useKeywordHistory'
import BillImportModal from './BillImportModal.vue'
@@ -27,7 +27,7 @@ interface TransactionItem {
categoryIcon: string
type: string // INCOME 或 EXPENSE
amount: number
paymentMethod: string
channel: string
remark: string
remarkHidden: boolean
transactionTime: string
@@ -185,7 +185,7 @@ const form = ref({
categoryId: null as number | null,
type: 'EXPENSE',
amount: null as number | null,
paymentMethod: '',
channel: '',
remark: '',
transactionTime: Date.now(),
includeInStats: true,
@@ -198,18 +198,18 @@ const typeOptions = computed(() => [
{ label: t('common.types.income'), value: 'INCOME' },
])
// 支付方式编码列表(从后端动态获取,含账单导入来源与人工支付方式)
const paymentMethodCodes = ref<string[]>([])
// 交易渠道编码列表(从后端动态获取,含账单导入来源与人工交易渠道)
const channelCodes = ref<string[]>([])
// 支付方式选项:由后端返回的编码映射为当前语言标签
const paymentOptions = computed(() =>
paymentMethodCodes.value.map((code) => ({ label: getPaymentLabel(code), value: code })),
// 交易渠道选项:由后端返回的编码映射为当前语言标签
const channelOptions = computed(() =>
channelCodes.value.map((code) => ({ label: getChannelLabel(code), value: code })),
)
// 筛选条件状态
const filterType = ref<string[]>([]) // 类型筛选(多选)
const filterCategory = ref<number[]>([]) // 分类筛选(多选)
const filterPaymentMethod = ref<string[]>([]) // 支付方式筛选(多选)
const filterChannel = ref<string[]>([]) // 交易渠道筛选(多选)
const filterDateRange = ref<[number, number] | null>(null) // 日期范围筛选(起止时间戳)
const filterIncludeInStats = ref<string | null>(null) // 计入统计筛选:null-全部,'true'-计入,'false'-不计入
const filterRemark = ref('') // 备注关键字搜索
@@ -293,7 +293,9 @@ function loadFilterPrefs() {
const p = JSON.parse(raw)
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.filterDateRange) && p.filterDateRange.length === 2) filterDateRange.value = p.filterDateRange
if (p.filterIncludeInStats === 'true' || p.filterIncludeInStats === 'false') filterIncludeInStats.value = p.filterIncludeInStats
if (typeof p.filterRemark === 'string') filterRemark.value = p.filterRemark
@@ -313,7 +315,7 @@ function saveFilterPrefs() {
localStorage.setItem(FILTER_PREFS_KEY, JSON.stringify({
filterType: filterType.value,
filterCategory: filterCategory.value,
filterPaymentMethod: filterPaymentMethod.value,
filterChannel: filterChannel.value,
filterDateRange: filterDateRange.value,
filterIncludeInStats: filterIncludeInStats.value,
filterRemark: filterRemark.value,
@@ -330,7 +332,7 @@ function saveFilterPrefs() {
// 筛选条件变化时自动保存用户偏好
watch(
[filterType, filterCategory, filterPaymentMethod, filterDateRange, filterIncludeInStats, filterRemark, filterMinAmount, filterMaxAmount, pageSize, sortValue, filterTag],
[filterType, filterCategory, filterChannel, filterDateRange, filterIncludeInStats, filterRemark, filterMinAmount, filterMaxAmount, pageSize, sortValue, filterTag],
saveFilterPrefs,
{ deep: true },
)
@@ -461,8 +463,8 @@ function createColumns(): DataTableColumns<TransactionItem> {
},
},
{
title: t('transaction.column.paymentMethod'), key: 'paymentMethod', width: 100,
render(row) { return getPaymentLabel(row.paymentMethod) },
title: t('transaction.column.channel'), key: 'channel', width: 100,
render(row) { return getChannelLabel(row.channel) },
},
{
title: t('transaction.column.remark'), key: 'remark', minWidth: 220,
@@ -523,13 +525,13 @@ function createColumns(): DataTableColumns<TransactionItem> {
const columns = computed(() => createColumns()) // 表格列配置(随语言切换响应式更新)
/** 从后端加载支付方式列表,供筛选与编辑表单使用 */
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) {
// 静默处理,支付方式下拉保持为空
// 静默处理,交易渠道下拉保持为空
}
}
@@ -562,7 +564,7 @@ async function fetchData() {
size: pageSize.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,
startTime: filterDateRange.value ? formatTimestamp(filterDateRange.value[0]) : undefined,
endTime: filterDateRange.value ? formatTimestamp(filterDateRange.value[1], true) : undefined,
includeInStats: filterIncludeInStats.value === null ? undefined : filterIncludeInStats.value === 'true',
@@ -607,7 +609,7 @@ function handleViewRaw(row: TransactionItem) {
/** 打开新增记录弹窗,重置表单 */
function handleAdd() {
editingId.value = null
form.value = { categoryId: null, type: 'EXPENSE', amount: null, paymentMethod: '', remark: '', transactionTime: Date.now(), includeInStats: true, tagIds: [] }
form.value = { categoryId: null, type: 'EXPENSE', amount: null, channel: '', remark: '', transactionTime: Date.now(), includeInStats: true, tagIds: [] }
pendingCategory.value = null
pendingTag.value = null
showModal.value = true
@@ -620,7 +622,7 @@ function handleEdit(row: TransactionItem) {
categoryId: row.categoryId,
type: row.type,
amount: row.amount,
paymentMethod: row.paymentMethod || '',
channel: row.channel || '',
remark: row.remark || '',
transactionTime: new Date(row.transactionTime).getTime(),
includeInStats: row.includeInStats ?? true,
@@ -646,7 +648,7 @@ async function handleSubmit() {
categoryId: form.value.categoryId!,
type: form.value.type,
amount: form.value.amount!,
paymentMethod: form.value.paymentMethod || undefined,
channel: form.value.channel || undefined,
remark: form.value.remark || undefined,
transactionTime: formatLocalDateTime(form.value.transactionTime),
includeInStats: form.value.includeInStats,
@@ -737,7 +739,7 @@ function handleSearch() {
function handleReset() {
filterType.value = []
filterCategory.value = []
filterPaymentMethod.value = []
filterChannel.value = []
filterDateRange.value = null
filterIncludeInStats.value = null
filterRemark.value = ''
@@ -797,14 +799,14 @@ async function handleAiSuggest() {
}
}
/** 编辑弹窗内 AI 推荐分类:基于表单当前内容(类型、金额、支付方式、备注) */
/** 编辑弹窗内 AI 推荐分类:基于表单当前内容(类型、金额、交易渠道、备注) */
async function handleAiSuggestOne() {
aiFormLoading.value = true
try {
const res: any = await suggestOneByAi({
type: form.value.type,
amount: form.value.amount,
paymentMethod: form.value.paymentMethod || undefined,
channel: form.value.channel || undefined,
remark: form.value.remark || undefined,
})
const suggestion = res.data
@@ -850,7 +852,7 @@ function buildCategoryPrompt() {
const parts = [`这是一笔${typeLabel}记录`]
if (form.value.remark) parts.push(`备注:${form.value.remark}`)
if (form.value.amount) parts.push(`金额:${form.value.amount}`)
if (form.value.paymentMethod) parts.push(`支付方式:${getPaymentLabel(form.value.paymentMethod)}`)
if (form.value.channel) parts.push(`交易渠道:${getChannelLabel(form.value.channel)}`)
parts.push('请生成一个能涵盖这类记录的记账分类')
return parts.join(',')
}
@@ -894,7 +896,7 @@ async function handleAiTagSuggest() {
const res: any = await suggestOneByAi({
type: form.value.type,
amount: form.value.amount,
paymentMethod: form.value.paymentMethod || undefined,
channel: form.value.channel || undefined,
remark: form.value.remark || undefined,
})
const suggestion = res.data
@@ -1055,7 +1057,7 @@ async function handleAiRecordParse() {
categoryId: r.categoryId ?? null,
type: r.type || 'EXPENSE',
amount: r.amount ?? null,
paymentMethod: r.paymentMethod || '',
channel: r.channel || '',
remark: r.remark || '',
transactionTime: r.transactionTime ? new Date(r.transactionTime).getTime() : Date.now(),
includeInStats: r.includeInStats ?? true,
@@ -1121,7 +1123,7 @@ function applyAiQuery() {
const categoryIds = [...(r.categoryIds || [])]
if (r.uncategorized) categoryIds.push(0)
filterCategory.value = categoryIds
filterPaymentMethod.value = r.paymentMethods || []
filterChannel.value = r.channels || []
filterDateRange.value = r.startTime && r.endTime
? [new Date(r.startTime).getTime(), new Date(r.endTime).getTime()]
: null
@@ -1143,7 +1145,7 @@ const aiQueryEmpty = computed(() => {
&& (r.categoryIds?.length ?? 0) === 0
&& !r.uncategorized
&& (r.tagIds?.length ?? 0) === 0
&& (r.paymentMethods?.length ?? 0) === 0
&& (r.channels?.length ?? 0) === 0
&& !r.startTime
&& r.minAmount == null
&& r.maxAmount == null
@@ -1226,11 +1228,11 @@ const activeFilterTags = computed(() => {
})
}
filterPaymentMethod.value.forEach((v) => {
filterChannel.value.forEach((v) => {
tags.push({
key: `payment:${v}`,
label: getPaymentLabel(v),
clear: () => { filterPaymentMethod.value = filterPaymentMethod.value.filter((x) => x !== v) },
key: `channel:`,
label: getChannelLabel(v),
clear: () => { filterChannel.value = filterChannel.value.filter((x) => x !== v) },
})
})
@@ -1451,7 +1453,7 @@ async function doExport() {
const res = await exportTransaction({
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,
startTime: filterDateRange.value ? formatTimestamp(filterDateRange.value[0]) : undefined,
endTime: filterDateRange.value ? formatTimestamp(filterDateRange.value[1], true) : undefined,
includeInStats: filterIncludeInStats.value === null ? undefined : filterIncludeInStats.value === 'true',
@@ -1487,7 +1489,7 @@ onMounted(() => {
loadAiConfig()
fetchCategories()
fetchTags()
loadPaymentMethods()
loadChannels()
fetchData()
})
</script>
@@ -1507,6 +1509,12 @@ onMounted(() => {
</NButton>
<NButton @click="showImportModal = true">{{ t('transaction.importBill') }}</NButton>
<NButton :loading="exporting" @click="handleExport">{{ t('transaction.exportExcel') }}</NButton>
<NButton :loading="loading" @click="fetchData">
<template #icon>
<NIcon :component="RefreshOutline" />
</template>
{{ t('common.refresh') }}
</NButton>
<NButton v-if="aiEnabled" @click="handleAiRecordOpen">
<template #icon>
<NIcon :component="SparklesOutline" />
@@ -1562,12 +1570,12 @@ onMounted(() => {
@blur="handleRemarkBlur"
/>
<NSelect
v-model:value="filterPaymentMethod"
:options="paymentOptions"
v-model:value="filterChannel"
:options="channelOptions"
multiple
clearable
:max-tag-count="2"
:placeholder="t('transaction.column.paymentMethod')"
:placeholder="t('transaction.column.channel')"
style="width: 180px;"
/>
<NPopover v-model:show="showMoreFilter" trigger="click" placement="bottom-start">
@@ -1721,8 +1729,8 @@ onMounted(() => {
<NFormItem :label="t('transaction.column.amount')">
<NInputNumber v-model:value="form.amount" :min="0.01" :precision="2" style="width: 100%;" :placeholder="t('transaction.amountPlaceholder')" />
</NFormItem>
<NFormItem :label="t('transaction.column.paymentMethod')">
<NSelect v-model:value="form.paymentMethod" :options="paymentOptions" clearable :placeholder="t('transaction.paymentPlaceholder')" />
<NFormItem :label="t('transaction.column.channel')">
<NSelect v-model:value="form.channel" :options="channelOptions" clearable :placeholder="t('transaction.channelPlaceholder')" />
</NFormItem>
<NFormItem :label="t('transaction.column.transactionTime')">
<NDatePicker v-model:value="form.transactionTime" type="datetime" style="width: 100%;" />