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:
@@ -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%;" />
|
||||
|
||||
Reference in New Issue
Block a user