feat: 扩展 AI 记账能力并优化收支记录交互
- AI 能力:新增分类 Emoji 推荐、标签颜色推荐、自然语言生成分类/标签、单条记录分类推荐 - AI 批量建议:按类型+支付方式+备注去重聚合后分批调用,支持分类/标签/两者范围并记忆偏好,结果可勾选应用 - 分类/标签管理:新增 EmojiPicker 与预设色板,支持 AI 一键推荐并在列表可视化 - 收支记录:新增序号列,标签筛选支持无标签,编辑记录时允许不设置分类 - 修复:账单导入未选择默认标签时生成空 IN 列表导致的 SQL 语法错误 - AI 未启用时隐藏所有 AI 入口与弹窗
This commit is contained in:
@@ -6,10 +6,10 @@ import { useMessage, useDialog, NButton, NSpace, NDataTable, NTag, NModal, NForm
|
||||
import { EyeOutline, EyeOffOutline, CloseCircleOutline, SparklesOutline, BulbOutline } from '@vicons/ionicons5'
|
||||
import type { DataTableColumns, AutoCompleteOption } from 'naive-ui'
|
||||
import { getTransactionPage, createTransaction, updateTransaction, deleteTransaction, exportTransaction, toggleTransactionStats, toggleRemarkHidden, batchUpdateCategory, batchTags } from '../api/transaction'
|
||||
import { getAiConfig, suggestByAi, applyAiSuggestions, queryByAi, parseRecordByAi } from '../api/ai'
|
||||
import { getAiConfig, suggestByAi, suggestOneByAi, applyAiSuggestions, queryByAi, parseRecordByAi, parseCategoryByAi, parseTagByAi } from '../api/ai'
|
||||
import type { AiSuggestion, AiQueryResult, AiRecordResult } from '../api/ai'
|
||||
import { getCategoryList } from '../api/category'
|
||||
import { getTagList } from '../api/tag'
|
||||
import { getCategoryList, createCategory } from '../api/category'
|
||||
import { getTagList, createTag } from '../api/tag'
|
||||
import BillImportModal from './BillImportModal.vue'
|
||||
|
||||
const message = useMessage() // 消息提示
|
||||
@@ -83,8 +83,47 @@ const showBatchModal = ref(false) // 批量设置分类弹窗显示状态
|
||||
const showAiModal = ref(false) // AI 建议弹窗显示状态
|
||||
const aiEnabled = ref(false) // AI 建议是否启用
|
||||
const aiLoading = ref(false) // AI 建议生成状态
|
||||
const aiFormLoading = ref(false) // 编辑弹窗内 AI 推荐状态
|
||||
/** AI 建议范围偏好存储键 */
|
||||
const AI_SCOPE_KEY = 'finance.transaction.aiScope'
|
||||
|
||||
/** 读取上次的 AI 建议范围偏好,非法值回退 BOTH */
|
||||
function loadAiScope(): string {
|
||||
try {
|
||||
const saved = localStorage.getItem(AI_SCOPE_KEY)
|
||||
return saved && ['BOTH', 'CATEGORY', 'TAG'].includes(saved) ? saved : 'BOTH'
|
||||
} catch (e) {
|
||||
return 'BOTH'
|
||||
}
|
||||
}
|
||||
|
||||
const aiScope = ref(loadAiScope()) // 批量 AI 建议范围:BOTH/CATEGORY/TAG(记忆用户偏好)
|
||||
// 批量 AI 建议范围选项
|
||||
const aiScopeOptions = computed(() => [
|
||||
{ label: t('ai.scope.both'), value: 'BOTH' },
|
||||
{ label: t('ai.scope.category'), value: 'CATEGORY' },
|
||||
{ label: t('ai.scope.tag'), value: 'TAG' },
|
||||
])
|
||||
|
||||
// 记住用户选择的 AI 建议范围
|
||||
watch(aiScope, (value) => {
|
||||
try {
|
||||
localStorage.setItem(AI_SCOPE_KEY, value)
|
||||
} catch (e) {
|
||||
// 忽略存储异常
|
||||
}
|
||||
})
|
||||
const aiNewLoading = ref(false) // 编辑弹窗内 AI 生成新分类状态
|
||||
const aiNewSaving = ref(false) // 新分类保存状态
|
||||
// AI 生成但尚未保存的新分类,需用户确认后再落库
|
||||
const pendingCategory = ref<{ name: string; type: string; icon: string } | null>(null)
|
||||
const aiTagLoading = ref(false) // 编辑弹窗内标签 AI 请求状态
|
||||
const aiTagSaving = ref(false) // 新标签保存状态
|
||||
// AI 生成但尚未保存的新标签,需用户确认后再落库
|
||||
const pendingTag = ref<{ name: string; color: string } | null>(null)
|
||||
const aiApplying = ref(false) // AI 建议应用状态
|
||||
const aiSuggestions = ref<AiSuggestion[]>([]) // AI 建议列表
|
||||
const checkedAiRowKeys = ref<number[]>([]) // AI 建议弹窗中已勾选的代表记录ID
|
||||
const aiQueryText = ref('') // AI 自然语言查询输入
|
||||
const aiQuerying = ref(false) // AI 查询解析状态
|
||||
const showAiQueryModal = ref(false) // AI 查询结果弹窗显示状态
|
||||
@@ -106,6 +145,9 @@ const batchIncludeInStats = ref<'null' | 'true' | 'false'>('null') // 批量设
|
||||
const batchAddTagIds = ref<number[]>([]) // 批量添加的标签
|
||||
const batchRemoveTagIds = ref<number[]>([]) // 批量移除的标签
|
||||
const batching = ref(false) // 批量设置提交状态
|
||||
// 当前表格是否包含收入/支出记录,用于按需展示批量分类项
|
||||
const hasIncomeRecords = computed(() => transactions.value.some((t) => t.type === 'INCOME'))
|
||||
const hasExpenseRecords = computed(() => transactions.value.some((t) => t.type === 'EXPENSE'))
|
||||
|
||||
// 批量设置计入统计选项
|
||||
const batchStatsOptions = computed(() => [
|
||||
@@ -190,6 +232,21 @@ const categoryFilterOptions = computed(() => [
|
||||
// 标签筛选项
|
||||
const tagFilterOptions = computed(() => allTags.value.map((t) => ({ label: t.name, value: t.id })))
|
||||
|
||||
// 标签筛选下拉项:额外提供「无标签」选项(仅筛选使用,编辑/批量选择不包含)
|
||||
const tagFilterSelectOptions = computed(() => [
|
||||
{ label: t('transaction.untagged'), value: 0 },
|
||||
...tagFilterOptions.value,
|
||||
])
|
||||
|
||||
/** 将标签筛选拆分为标签ID与无标签标记,供查询与导出复用 */
|
||||
function buildTagFilter() {
|
||||
const ids = filterTag.value.filter((v) => v !== 0)
|
||||
return {
|
||||
tagIds: ids.length > 0 ? ids : undefined,
|
||||
untagged: filterTag.value.includes(0) ? true : undefined,
|
||||
}
|
||||
}
|
||||
|
||||
/** 将分类筛选拆分为分类ID与未分类标记,供查询与导出复用 */
|
||||
function buildCategoryFilter() {
|
||||
const ids = filterCategory.value.filter((v) => v !== 0)
|
||||
@@ -427,6 +484,13 @@ async function setRemarkHidden(row: TransactionItem, hidden: boolean) {
|
||||
/** 创建表格列配置 */
|
||||
function createColumns(): DataTableColumns<TransactionItem> {
|
||||
return [
|
||||
{
|
||||
// 序号列:按分页计算全局序号
|
||||
title: t('transaction.column.index'), key: 'index', width: 70, align: 'center',
|
||||
render(_row, index) {
|
||||
return (page.value - 1) * pageSize.value + index + 1
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t('transaction.column.transactionTime'), key: 'transactionTime', width: 170,
|
||||
render(row) {
|
||||
@@ -562,7 +626,7 @@ async function fetchData() {
|
||||
remark: filterRemark.value.trim() || undefined,
|
||||
sortField: sortField.value,
|
||||
sortOrder: sortOrder.value,
|
||||
tagIds: filterTag.value.length > 0 ? filterTag.value : undefined,
|
||||
...buildTagFilter(),
|
||||
})
|
||||
transactions.value = res.data.records
|
||||
total.value = res.data.total
|
||||
@@ -598,6 +662,8 @@ 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: [] }
|
||||
pendingCategory.value = null
|
||||
pendingTag.value = null
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
@@ -614,12 +680,15 @@ function handleEdit(row: TransactionItem) {
|
||||
includeInStats: row.includeInStats ?? true,
|
||||
tagIds: (row.tags || []).map((t) => t.id),
|
||||
}
|
||||
pendingCategory.value = null
|
||||
pendingTag.value = null
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
/** 提交表单:校验 -> 新增或更新 -> 刷新列表 */
|
||||
async function handleSubmit() {
|
||||
if (!form.value.categoryId) {
|
||||
// 编辑已有记录时允许不设置分类,便于只修改标签等信息
|
||||
if (!form.value.categoryId && !editingId.value) {
|
||||
message.warning(t('transaction.selectCategory'))
|
||||
return
|
||||
}
|
||||
@@ -736,12 +805,15 @@ async function handleAiSuggest() {
|
||||
aiLoading.value = true
|
||||
try {
|
||||
const ids = transactions.value.map((t) => t.id)
|
||||
const res: any = await suggestByAi(ids)
|
||||
const res: any = await suggestByAi(ids, aiScope.value)
|
||||
aiSuggestions.value = res.data || []
|
||||
if (aiSuggestions.value.length === 0) {
|
||||
message.warning(t('ai.noSuggestion'))
|
||||
return
|
||||
}
|
||||
// 默认全选,用户可取消不需要的建议
|
||||
checkedAiRowKeys.value = aiSuggestions.value.map((s) => s.transactionId)
|
||||
message.success(t('ai.aggregated', { groups: aiSuggestions.value.length, count: aiTotalCount.value }))
|
||||
showBatchModal.value = false
|
||||
showAiModal.value = true
|
||||
} catch (e: any) {
|
||||
@@ -751,18 +823,197 @@ async function handleAiSuggest() {
|
||||
}
|
||||
}
|
||||
|
||||
/** 编辑弹窗内 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,
|
||||
remark: form.value.remark || undefined,
|
||||
})
|
||||
const suggestion = res.data
|
||||
if (!suggestion || suggestion.categoryId == null) {
|
||||
message.warning(t('ai.noSuggestion'))
|
||||
return
|
||||
}
|
||||
form.value.categoryId = suggestion.categoryId
|
||||
message.success(t('ai.oneApplied'))
|
||||
} catch (e: any) {
|
||||
message.error(e.message || t('ai.suggestFailed'))
|
||||
} finally {
|
||||
aiFormLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 编辑弹窗内根据账单信息生成新分类(暂不保存,等待用户确认) */
|
||||
async function handleAiNewCategory() {
|
||||
if (!form.value.remark && !form.value.amount) {
|
||||
message.warning(t('ai.newCategory.needInfo'))
|
||||
return
|
||||
}
|
||||
aiNewLoading.value = true
|
||||
try {
|
||||
const res: any = await parseCategoryByAi(buildCategoryPrompt())
|
||||
const data = res.data
|
||||
if (!data || !data.name) {
|
||||
message.warning(t('ai.category.failed'))
|
||||
return
|
||||
}
|
||||
// 类型跟随当前记录,避免与所选收支类型不一致
|
||||
pendingCategory.value = { name: data.name, type: form.value.type, icon: data.icon || '' }
|
||||
} catch (e: any) {
|
||||
message.error(e.message || t('ai.category.failed'))
|
||||
} finally {
|
||||
aiNewLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 拼接用于生成分类的账单描述 */
|
||||
function buildCategoryPrompt() {
|
||||
const typeLabel = form.value.type === 'INCOME' ? t('common.types.income') : t('common.types.expense')
|
||||
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)}`)
|
||||
parts.push('请生成一个能涵盖这类记录的记账分类')
|
||||
return parts.join(',')
|
||||
}
|
||||
|
||||
/** 确认保存 AI 生成的新分类并选用 */
|
||||
async function handleSaveNewCategory() {
|
||||
const pending = pendingCategory.value
|
||||
if (!pending) {
|
||||
return
|
||||
}
|
||||
// 已存在同名同类型分类时直接选用,避免重复创建
|
||||
const existing = categories.value.find((c) => c.label === pending.name && c.type === pending.type)
|
||||
if (existing) {
|
||||
form.value.categoryId = existing.value
|
||||
message.info(t('ai.newCategory.exists'))
|
||||
pendingCategory.value = null
|
||||
return
|
||||
}
|
||||
aiNewSaving.value = true
|
||||
try {
|
||||
await createCategory({ name: pending.name, type: pending.type, icon: pending.icon, sortOrder: 0 })
|
||||
await fetchCategories()
|
||||
// 排序值相同,取同名同类型中的最后一个(最新创建)
|
||||
const created = [...categories.value].reverse().find((c) => c.label === pending.name && c.type === pending.type)
|
||||
if (created) {
|
||||
form.value.categoryId = created.value
|
||||
}
|
||||
message.success(t('ai.newCategory.saved'))
|
||||
pendingCategory.value = null
|
||||
} catch (e: any) {
|
||||
message.error(e.message || t('common.operationFailed'))
|
||||
} finally {
|
||||
aiNewSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 编辑弹窗内 AI 推荐已有标签:基于表单当前内容,追加到已选标签 */
|
||||
async function handleAiTagSuggest() {
|
||||
aiTagLoading.value = true
|
||||
try {
|
||||
const res: any = await suggestOneByAi({
|
||||
type: form.value.type,
|
||||
amount: form.value.amount,
|
||||
paymentMethod: form.value.paymentMethod || undefined,
|
||||
remark: form.value.remark || undefined,
|
||||
})
|
||||
const suggestion = res.data
|
||||
if (!suggestion || !suggestion.tagIds || suggestion.tagIds.length === 0) {
|
||||
message.warning(t('ai.noSuggestion'))
|
||||
return
|
||||
}
|
||||
// 合并去重,保留已选标签
|
||||
form.value.tagIds = Array.from(new Set([...form.value.tagIds, ...suggestion.tagIds]))
|
||||
message.success(t('ai.tagApplied'))
|
||||
} catch (e: any) {
|
||||
message.error(e.message || t('ai.suggestFailed'))
|
||||
} finally {
|
||||
aiTagLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 编辑弹窗内根据账单信息生成新标签(暂不保存,等待用户确认) */
|
||||
async function handleAiNewTag() {
|
||||
if (!form.value.remark && !form.value.amount) {
|
||||
message.warning(t('ai.newTag.needInfo'))
|
||||
return
|
||||
}
|
||||
aiTagLoading.value = true
|
||||
try {
|
||||
const res: any = await parseTagByAi(buildCategoryPrompt())
|
||||
const data = res.data
|
||||
if (!data || !data.name) {
|
||||
message.warning(t('ai.newTag.failed'))
|
||||
return
|
||||
}
|
||||
pendingTag.value = { name: data.name, color: data.color || '#2080f0' }
|
||||
} catch (e: any) {
|
||||
message.error(e.message || t('ai.newTag.failed'))
|
||||
} finally {
|
||||
aiTagLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 确认保存 AI 生成的新标签并选用 */
|
||||
async function handleSaveNewTag() {
|
||||
const pending = pendingTag.value
|
||||
if (!pending) {
|
||||
return
|
||||
}
|
||||
// 已存在同名标签时直接选用,避免重复创建
|
||||
const existing = allTags.value.find((tag) => tag.name === pending.name)
|
||||
if (existing) {
|
||||
addTagToForm(existing.id)
|
||||
message.info(t('ai.newTag.exists'))
|
||||
pendingTag.value = null
|
||||
return
|
||||
}
|
||||
aiTagSaving.value = true
|
||||
try {
|
||||
await createTag({ name: pending.name, color: pending.color, sortOrder: 0 })
|
||||
await fetchTags()
|
||||
// 排序值相同,取同名中的最后一个(最新创建)
|
||||
const created = [...allTags.value].reverse().find((tag) => tag.name === pending.name)
|
||||
if (created) {
|
||||
addTagToForm(created.id)
|
||||
}
|
||||
message.success(t('ai.newTag.saved'))
|
||||
pendingTag.value = null
|
||||
} catch (e: any) {
|
||||
message.error(e.message || t('common.operationFailed'))
|
||||
} finally {
|
||||
aiTagSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** 将标签追加到表单已选标签中(去重) */
|
||||
function addTagToForm(tagId: number) {
|
||||
if (!form.value.tagIds.includes(tagId)) {
|
||||
form.value.tagIds = [...form.value.tagIds, tagId]
|
||||
}
|
||||
}
|
||||
|
||||
/** 一键应用全部 AI 建议:覆盖分类并追加标签 */
|
||||
async function handleAiApply() {
|
||||
if (aiSuggestions.value.length === 0) {
|
||||
const selected = new Set(checkedAiRowKeys.value)
|
||||
const chosen = aiSuggestions.value.filter((s) => selected.has(s.transactionId))
|
||||
if (chosen.length === 0) {
|
||||
message.warning(t('ai.applyNone'))
|
||||
return
|
||||
}
|
||||
aiApplying.value = true
|
||||
try {
|
||||
const payload = aiSuggestions.value.map((s) => ({
|
||||
transactionId: s.transactionId,
|
||||
categoryId: s.categoryId,
|
||||
tagIds: s.tagIds || [],
|
||||
}))
|
||||
// 仅提交勾选的建议,聚合建议按组展开为逐条记录
|
||||
const payload = chosen.flatMap((s) => {
|
||||
const ids = s.transactionIds?.length ? s.transactionIds : [s.transactionId]
|
||||
return ids.map((id) => ({ transactionId: id, categoryId: s.categoryId, tagIds: s.tagIds || [] }))
|
||||
})
|
||||
const res: any = await applyAiSuggestions(payload)
|
||||
message.success(t('ai.applySuccess', { count: res.data }))
|
||||
showAiModal.value = false
|
||||
@@ -1042,7 +1293,9 @@ const activeFilterTags = computed(() => {
|
||||
})
|
||||
|
||||
filterTag.value.forEach((id) => {
|
||||
const name = allTags.value.find((tag) => tag.id === id)?.name || String(id)
|
||||
const name = id === 0
|
||||
? t('transaction.untagged')
|
||||
: (allTags.value.find((tag) => tag.id === id)?.name || String(id))
|
||||
tags.push({
|
||||
key: `tag:${id}`,
|
||||
label: name,
|
||||
@@ -1075,7 +1328,7 @@ function applyTagClear(tag: { clear: () => void }) {
|
||||
handleSearch()
|
||||
}
|
||||
|
||||
/** AI 建议行:合并交易记录信息用于展示 */
|
||||
/** AI 建议行:合并代表交易记录信息用于展示 */
|
||||
const aiRows = computed(() =>
|
||||
aiSuggestions.value.map((s) => ({
|
||||
...s,
|
||||
@@ -1083,28 +1336,46 @@ const aiRows = computed(() =>
|
||||
})),
|
||||
)
|
||||
|
||||
/** AI 建议表格列定义(随语言切换响应式更新) */
|
||||
const aiColumns = computed<DataTableColumns<any>>(() => [
|
||||
{
|
||||
title: t('ai.column.record'), key: 'record', minWidth: 200,
|
||||
render: (row) => {
|
||||
const tx = row.transaction
|
||||
if (!tx) return `#${row.transactionId}`
|
||||
const time = tx.transactionTime?.replace('T', ' ').substring(0, 16) || '-'
|
||||
const sign = tx.type === 'INCOME' ? '+' : '-'
|
||||
return h('div', null, [
|
||||
h('div', null, `${time} ${sign}${tx.amount.toFixed(2)}`),
|
||||
h('div', { style: { color: '#999', fontSize: '12px' } }, tx.remark || '-'),
|
||||
])
|
||||
/** 已勾选 AI 建议覆盖的记录总数(聚合组按组内记录数累加) */
|
||||
const aiTotalCount = computed(() => {
|
||||
const selected = new Set(checkedAiRowKeys.value)
|
||||
return aiSuggestions.value
|
||||
.filter((s) => selected.has(s.transactionId))
|
||||
.reduce((sum, s) => sum + (s.transactionIds?.length || 1), 0)
|
||||
})
|
||||
|
||||
/** AI 建议表格列定义(随语言切换与建议范围响应式更新) */
|
||||
const aiColumns = computed<DataTableColumns<any>>(() => {
|
||||
const columns: DataTableColumns<any> = [
|
||||
{ type: 'selection' },
|
||||
{
|
||||
title: t('ai.column.record'), key: 'record', minWidth: 200,
|
||||
render: (row) => {
|
||||
const tx = row.transaction
|
||||
if (!tx) return `#${row.transactionId}`
|
||||
const time = tx.transactionTime?.replace('T', ' ').substring(0, 16) || '-'
|
||||
const sign = tx.type === 'INCOME' ? '+' : '-'
|
||||
return h('div', null, [
|
||||
h('div', null, `${time} ${sign}${tx.amount.toFixed(2)}`),
|
||||
h('div', { style: { color: '#999', fontSize: '12px' } }, tx.remark || '-'),
|
||||
])
|
||||
},
|
||||
},
|
||||
},
|
||||
{ title: t('ai.column.category'), key: 'category', width: 130, render: (row) => row.categoryName || '-' },
|
||||
{
|
||||
title: t('ai.column.tags'), key: 'tags', minWidth: 150,
|
||||
render: (row) => (row.tagNames && row.tagNames.length > 0 ? row.tagNames.join('、') : '-'),
|
||||
},
|
||||
{ title: t('ai.column.reason'), key: 'reason', minWidth: 180, ellipsis: { tooltip: true } },
|
||||
])
|
||||
{ title: t('ai.column.count'), key: 'count', width: 90, render: (row) => row.count ?? 1 },
|
||||
]
|
||||
// 按建议范围展示对应列
|
||||
if (aiScope.value !== 'TAG') {
|
||||
columns.push({ title: t('ai.column.category'), key: 'category', width: 130, render: (row) => row.categoryName || '-' })
|
||||
}
|
||||
if (aiScope.value !== 'CATEGORY') {
|
||||
columns.push({
|
||||
title: t('ai.column.tags'), key: 'tags', minWidth: 150,
|
||||
render: (row) => (row.tagNames && row.tagNames.length > 0 ? row.tagNames.join('、') : '-'),
|
||||
})
|
||||
}
|
||||
columns.push({ title: t('ai.column.reason'), key: 'reason', minWidth: 180, ellipsis: { tooltip: true } })
|
||||
return columns
|
||||
})
|
||||
|
||||
/** 打开批量设置分类弹窗 */
|
||||
function handleBatchOpen() {
|
||||
@@ -1171,7 +1442,7 @@ async function handleExport() {
|
||||
remark: filterRemark.value.trim() || undefined,
|
||||
sortField: sortField.value,
|
||||
sortOrder: sortOrder.value,
|
||||
tagIds: filterTag.value.length > 0 ? filterTag.value : undefined,
|
||||
...buildTagFilter(),
|
||||
})
|
||||
const blob = new Blob([res.data], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' })
|
||||
const url = window.URL.createObjectURL(blob)
|
||||
@@ -1325,7 +1596,7 @@ onMounted(() => {
|
||||
<div class="filter-label">{{ t('transaction.column.tags') }}</div>
|
||||
<NSelect
|
||||
v-model:value="filterTag"
|
||||
:options="tagFilterOptions"
|
||||
:options="tagFilterSelectOptions"
|
||||
multiple
|
||||
clearable
|
||||
filterable
|
||||
@@ -1429,8 +1700,19 @@ onMounted(() => {
|
||||
<NSelect v-model:value="form.type" :options="typeOptions" />
|
||||
</NFormItem>
|
||||
<NFormItem :label="t('transaction.column.category')">
|
||||
<NSelect v-model:value="form.categoryId" :options="categories.filter(c => c.type === form.type)" :render-label="renderCategoryLabel" :placeholder="t('transaction.selectCategoryPlaceholder')" filterable />
|
||||
<div style="display: flex; gap: 8px; width: 100%;">
|
||||
<NSelect v-model:value="form.categoryId" :options="categories.filter(c => c.type === form.type)" :render-label="renderCategoryLabel" :placeholder="t('transaction.selectCategoryPlaceholder')" filterable style="flex: 1;" />
|
||||
<NButton v-if="aiEnabled" size="small" :loading="aiFormLoading" @click="handleAiSuggestOne">{{ t('ai.pickButton') }}</NButton>
|
||||
<NButton v-if="aiEnabled" size="small" :loading="aiNewLoading" @click="handleAiNewCategory">{{ t('ai.newCategory.button') }}</NButton>
|
||||
</div>
|
||||
</NFormItem>
|
||||
<!-- AI 生成的新分类确认条:用户确认后才保存 -->
|
||||
<div v-if="pendingCategory" style="display: flex; align-items: center; gap: 8px; margin-bottom: 18px; padding: 8px 12px; background: #f7f7fa; border-radius: 4px; font-size: 13px;">
|
||||
<span>{{ t('ai.newCategory.hint') }}</span>
|
||||
<span style="font-weight: 600;">{{ pendingCategory.icon }} {{ pendingCategory.name }}</span>
|
||||
<NButton size="tiny" type="primary" :loading="aiNewSaving" @click="handleSaveNewCategory">{{ t('ai.newCategory.save') }}</NButton>
|
||||
<NButton size="tiny" @click="pendingCategory = null">{{ t('common.cancel') }}</NButton>
|
||||
</div>
|
||||
<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>
|
||||
@@ -1444,15 +1726,27 @@ onMounted(() => {
|
||||
<NInput v-model:value="form.remark" type="textarea" :placeholder="t('transaction.remarkPlaceholder')" />
|
||||
</NFormItem>
|
||||
<NFormItem :label="t('transaction.column.tags')">
|
||||
<NSelect
|
||||
v-model:value="form.tagIds"
|
||||
:options="tagFilterOptions"
|
||||
multiple
|
||||
clearable
|
||||
filterable
|
||||
:placeholder="t('transaction.tagPlaceholder')"
|
||||
/>
|
||||
<div style="display: flex; gap: 8px; width: 100%;">
|
||||
<NSelect
|
||||
v-model:value="form.tagIds"
|
||||
:options="tagFilterOptions"
|
||||
multiple
|
||||
clearable
|
||||
filterable
|
||||
:placeholder="t('transaction.tagPlaceholder')"
|
||||
style="flex: 1;"
|
||||
/>
|
||||
<NButton v-if="aiEnabled" size="small" :loading="aiTagLoading" @click="handleAiTagSuggest">{{ t('ai.pickButton') }}</NButton>
|
||||
<NButton v-if="aiEnabled" size="small" :loading="aiTagLoading" @click="handleAiNewTag">{{ t('ai.newTag.button') }}</NButton>
|
||||
</div>
|
||||
</NFormItem>
|
||||
<!-- AI 生成的新标签确认条:用户确认后才保存 -->
|
||||
<div v-if="pendingTag" style="display: flex; align-items: center; gap: 8px; margin-bottom: 18px; padding: 8px 12px; background: #f7f7fa; border-radius: 4px; font-size: 13px;">
|
||||
<span>{{ t('ai.newTag.hint') }}</span>
|
||||
<NTag size="small" :color="{ color: pendingTag.color, textColor: '#fff' }">{{ pendingTag.name }}</NTag>
|
||||
<NButton size="tiny" type="primary" :loading="aiTagSaving" @click="handleSaveNewTag">{{ t('ai.newTag.save') }}</NButton>
|
||||
<NButton size="tiny" @click="pendingTag = null">{{ t('common.cancel') }}</NButton>
|
||||
</div>
|
||||
<NFormItem :label="t('transaction.includeInStats')">
|
||||
<NSwitch v-model:value="form.includeInStats" />
|
||||
<span style="color: #999; margin-left: 8px; font-size: 12px;">{{ t('transaction.includeInStatsHint') }}</span>
|
||||
@@ -1478,9 +1772,12 @@ onMounted(() => {
|
||||
</span>
|
||||
</NFormItem>
|
||||
<NFormItem v-if="aiEnabled" :label="t('ai.suggestButton')">
|
||||
<NButton :loading="aiLoading" @click="handleAiSuggest">{{ t('ai.suggestButton') }}</NButton>
|
||||
<div style="display: flex; gap: 8px; width: 100%;">
|
||||
<NSelect v-model:value="aiScope" :options="aiScopeOptions" style="flex: 1;" />
|
||||
<NButton :loading="aiLoading" @click="handleAiSuggest">{{ t('ai.suggestButton') }}</NButton>
|
||||
</div>
|
||||
</NFormItem>
|
||||
<NFormItem :label="t('transaction.expenseCategory')">
|
||||
<NFormItem v-if="hasExpenseRecords" :label="t('transaction.expenseCategory')">
|
||||
<NSelect
|
||||
v-model:value="batchExpenseCategoryId"
|
||||
:options="categories.filter(c => c.type === 'EXPENSE')"
|
||||
@@ -1490,7 +1787,7 @@ onMounted(() => {
|
||||
:placeholder="t('transaction.notSetExpense')"
|
||||
/>
|
||||
</NFormItem>
|
||||
<NFormItem :label="t('transaction.incomeCategory')">
|
||||
<NFormItem v-if="hasIncomeRecords" :label="t('transaction.incomeCategory')">
|
||||
<NSelect
|
||||
v-model:value="batchIncomeCategoryId"
|
||||
:options="categories.filter(c => c.type === 'INCOME')"
|
||||
@@ -1514,10 +1811,11 @@ onMounted(() => {
|
||||
</NModal>
|
||||
|
||||
<!-- AI 建议弹窗(一键应用分类与标签建议) -->
|
||||
<NModal v-model:show="showAiModal" preset="card" :title="t('ai.suggestTitle')" style="width: 860px; max-width: 92vw;">
|
||||
<NModal v-if="aiEnabled" v-model:show="showAiModal" preset="card" :title="t('ai.suggestTitle')" style="width: 860px; max-width: 92vw;">
|
||||
<NSpace vertical :size="12">
|
||||
<NAlert type="warning" :show-icon="true">{{ t('ai.applyHint') }}</NAlert>
|
||||
<NDataTable
|
||||
v-model:checked-row-keys="checkedAiRowKeys"
|
||||
:columns="aiColumns"
|
||||
:data="aiRows"
|
||||
:max-height="420"
|
||||
@@ -1527,15 +1825,15 @@ onMounted(() => {
|
||||
/>
|
||||
<NSpace justify="end">
|
||||
<NButton @click="showAiModal = false">{{ t('common.cancel') }}</NButton>
|
||||
<NButton type="primary" :loading="aiApplying" :disabled="aiRows.length === 0" @click="handleAiApply">
|
||||
{{ t('ai.applyAll', { count: aiRows.length }) }}
|
||||
<NButton type="primary" :loading="aiApplying" :disabled="checkedAiRowKeys.length === 0" @click="handleAiApply">
|
||||
{{ t('ai.applyAll', { count: aiTotalCount }) }}
|
||||
</NButton>
|
||||
</NSpace>
|
||||
</NSpace>
|
||||
</NModal>
|
||||
|
||||
<!-- AI 自然语言查询解析结果弹窗 -->
|
||||
<NModal v-model:show="showAiQueryModal" preset="card" :title="t('ai.query.resultTitle')" style="width: 560px; max-width: 92vw;">
|
||||
<NModal v-if="aiEnabled" v-model:show="showAiQueryModal" preset="card" :title="t('ai.query.resultTitle')" style="width: 560px; max-width: 92vw;">
|
||||
<NSpace vertical :size="12">
|
||||
<NAlert v-if="aiQueryResult?.summary" type="info" :show-icon="true">{{ aiQueryResult.summary }}</NAlert>
|
||||
<NText v-if="aiQueryEmpty" depth="3">{{ t('ai.query.noCondition') }}</NText>
|
||||
@@ -1590,7 +1888,7 @@ onMounted(() => {
|
||||
</NModal>
|
||||
|
||||
<!-- AI 智能记账弹窗 -->
|
||||
<NModal v-model:show="showAiRecordModal" preset="card" :title="t('ai.record.title')" style="width: 520px; max-width: 92vw;">
|
||||
<NModal v-if="aiEnabled" v-model:show="showAiRecordModal" preset="card" :title="t('ai.record.title')" style="width: 520px; max-width: 92vw;">
|
||||
<NSpace vertical :size="12">
|
||||
<NAlert type="info" :show-icon="true">{{ t('ai.record.hint') }}</NAlert>
|
||||
<NInput
|
||||
|
||||
Reference in New Issue
Block a user