feat: 接入 DeepSeek,新增 AI 分类建议、自然语言查询与智能记账

- 后端新增 AI 模块:DeepSeek 客户端、用户级配置表 t_ai_config、用量记录表 t_ai_usage
- 批量设置支持 AI 推荐分类与标签,并可一键应用
- 交易列表支持自然语言查询,解析为筛选条件确认后应用
- 支持自然语言记账,解析后预填新增表单
- 新增 AI 设置页:启用开关、API Key、接口地址、模型、连通性测试与用量统计
- 交易列表重排:AI 查询区、已激活筛选标签、备注与查询历史缓存
- 账单导入默认分类下拉补充分类图标
- 顶部用户入口改为下拉菜单(AI 设置 / 退出登录)
This commit is contained in:
2026-09-17 18:26:03 +08:00
parent 10fa1b5e96
commit 49c70a732d
38 changed files with 3951 additions and 27 deletions
+692 -11
View File
@@ -2,10 +2,12 @@
// 收支记录管理组件:展示交易记录列表(分页),支持新增、编辑、删除、多条件筛选
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 } from 'naive-ui'
import { EyeOutline, EyeOffOutline } from '@vicons/ionicons5'
import type { DataTableColumns } from 'naive-ui'
import { useMessage, useDialog, NButton, NSpace, NDataTable, NTag, NModal, NForm, NFormItem, NInput, NInputNumber, NSelect, NDatePicker, NPagination, NCard, NSwitch, NIcon, NPopover, NAutoComplete, NAlert, NDescriptions, NDescriptionsItem, NDivider, NDropdown } from 'naive-ui'
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 type { AiSuggestion, AiQueryResult, AiRecordResult } from '../api/ai'
import { getCategoryList } from '../api/category'
import { getTagList } from '../api/tag'
import BillImportModal from './BillImportModal.vue'
@@ -78,6 +80,24 @@ const loading = ref(false) // 列表加载状态
const showModal = ref(false) // 新增/编辑弹窗显示状态
const showImportModal = ref(false) // 账单导入弹窗显示状态
const showBatchModal = ref(false) // 批量设置分类弹窗显示状态
const showAiModal = ref(false) // AI 建议弹窗显示状态
const aiEnabled = ref(false) // AI 建议是否启用
const aiLoading = ref(false) // AI 建议生成状态
const aiApplying = ref(false) // AI 建议应用状态
const aiSuggestions = ref<AiSuggestion[]>([]) // AI 建议列表
const aiQueryText = ref('') // AI 自然语言查询输入
const aiQuerying = ref(false) // AI 查询解析状态
const showAiQueryModal = ref(false) // AI 查询结果弹窗显示状态
const aiQueryResult = ref<AiQueryResult | null>(null) // AI 查询解析结果
// AI 查询历史存储键与上限
const AI_QUERY_HISTORY_KEY = 'finance.transaction.aiQueryHistory'
const AI_QUERY_HISTORY_MAX = 10
// 下拉中“清空历史”选项的哨兵值
const CLEAR_AI_QUERY_HISTORY = '__clear_ai_query_history__'
const aiQueryHistory = ref<string[]>([]) // AI 查询历史(最近使用,最新在前)
const showAiRecordModal = ref(false) // AI 记账弹窗显示状态
const aiRecordText = ref('') // AI 记账输入
const aiRecording = ref(false) // AI 记账解析状态
const showRawModal = ref(false) // 原始账单数据弹窗显示状态
const rawDataText = ref('') // 格式化后的原始账单数据文本
const batchExpenseCategoryId = ref<number | null>(null) // 批量设置的支出分类
@@ -131,6 +151,12 @@ const filterPaymentMethod = ref<string[]>([]) // 支付方式筛选(多选)
const filterDateRange = ref<[number, number] | null>(null) // 日期范围筛选(起止时间戳)
const filterIncludeInStats = ref<string | null>(null) // 计入统计筛选:null-全部,'true'-计入,'false'-不计入
const filterRemark = ref('') // 备注关键字搜索
// 备注关键词历史存储键与上限
const REMARK_HISTORY_KEY = 'finance.transaction.remarkHistory'
const REMARK_HISTORY_MAX = 10
// 下拉中“清空历史”选项的哨兵值
const CLEAR_REMARK_HISTORY = '__clear_remark_history__'
const remarkHistory = ref<string[]>([]) // 备注关键词历史(最近输入,最新在前)
const filterMinAmount = ref<number | null>(null) // 最小金额筛选
const filterMaxAmount = ref<number | null>(null) // 最大金额筛选
const filterTag = ref<number[]>([]) // 标签筛选(多选)
@@ -232,6 +258,103 @@ watch(
{ deep: true },
)
/** 从 localStorage 读取备注关键词历史,忽略损坏数据 */
function loadRemarkHistory() {
try {
const raw = localStorage.getItem(REMARK_HISTORY_KEY)
const parsed = raw ? JSON.parse(raw) : []
remarkHistory.value = Array.isArray(parsed)
? parsed.filter((item) => typeof item === 'string' && item.trim()).slice(0, REMARK_HISTORY_MAX)
: []
} catch (e) {
remarkHistory.value = []
}
}
/** 记录一次备注关键词:去重(忽略大小写)、最新在前、上限 10 条 */
function recordRemarkKeyword() {
const value = filterRemark.value.trim()
if (!value) return
const rest = remarkHistory.value.filter((item) => item.toLowerCase() !== value.toLowerCase())
remarkHistory.value = [value, ...rest].slice(0, REMARK_HISTORY_MAX)
try {
localStorage.setItem(REMARK_HISTORY_KEY, JSON.stringify(remarkHistory.value))
} catch (e) {
// 忽略写入失败
}
}
/** 清空备注关键词历史 */
function clearRemarkHistory() {
remarkHistory.value = []
try {
localStorage.removeItem(REMARK_HISTORY_KEY)
} catch (e) {
// 忽略删除失败
}
}
// 备注历史下拉选项:按当前输入过滤历史项(忽略大小写),历史非空时末尾追加“清空历史”
const remarkOptions = computed<AutoCompleteOption[]>(() => {
const keyword = filterRemark.value.trim().toLowerCase()
const options: AutoCompleteOption[] = remarkHistory.value
.filter((item) => item.toLowerCase().includes(keyword))
.map((item) => ({ label: item, value: item }))
if (remarkHistory.value.length > 0) {
options.push({ label: CLEAR_REMARK_HISTORY, value: CLEAR_REMARK_HISTORY })
}
return options
})
/** 输入同步:清空项执行清空,其余写入输入框(NAutoComplete 回调传入的是选项 label) */
function handleRemarkInput(value: string | null) {
if (value === CLEAR_REMARK_HISTORY) {
clearRemarkHistory()
return
}
filterRemark.value = value || ''
}
/** 选中历史项:填入并立即搜索 */
function handleRemarkSelect(value: string) {
if (value === CLEAR_REMARK_HISTORY) {
clearRemarkHistory()
return
}
filterRemark.value = value
handleSearch()
}
/** 失焦时记录当前备注关键词 */
function handleRemarkBlur() {
recordRemarkKeyword()
}
/** 回车时记录关键词并搜索(阻止 NAutoComplete 内部选中建议项,保持原回车搜索语义) */
function handleRemarkKeydown(e: KeyboardEvent) {
if (e.key === 'Enter') {
e.stopPropagation()
recordRemarkKeyword()
handleSearch()
}
}
/** 聚焦即展示历史下拉(NAutoComplete 默认仅在输入非空时展示) */
function showRemarkMenu() {
return true
}
/** 渲染下拉选项,清空项使用弱化样式与图标 */
function renderRemarkLabel(option: AutoCompleteOption) {
if (String(option.value) === CLEAR_REMARK_HISTORY) {
return h('span', { style: { display: 'flex', alignItems: 'center', gap: '4px', color: '#999' } }, [
h(NIcon, { size: 16 }, { default: () => h(CloseCircleOutline) }),
t('transaction.remarkHistoryClear'),
])
}
return option.label as string
}
/** 将时间戳格式化为 ISO 格式的日期时间字符串(YYYY-MM-DDTHH:mm:ss) */
function formatTimestamp(ts: number, isEnd = false): string {
const d = new Date(ts)
@@ -593,6 +716,396 @@ async function handleToggleStats(row: TransactionItem, value: boolean) {
}
}
/** 加载 AI 启用状态,用于控制批量设置中的入口显示 */
async function loadAiConfig() {
try {
const res: any = await getAiConfig()
aiEnabled.value = !!res.data.enabled
} catch (e) {
// 静默处理,AI 入口保持隐藏
aiEnabled.value = false
}
}
/** 请求 AI 建议:对当前表格展示的记录生成分类与标签建议 */
async function handleAiSuggest() {
if (transactions.value.length === 0) {
message.warning(t('transaction.noRecordsToSet'))
return
}
aiLoading.value = true
try {
const ids = transactions.value.map((t) => t.id)
const res: any = await suggestByAi(ids)
aiSuggestions.value = res.data || []
if (aiSuggestions.value.length === 0) {
message.warning(t('ai.noSuggestion'))
return
}
showBatchModal.value = false
showAiModal.value = true
} catch (e: any) {
message.error(e.message || t('ai.suggestFailed'))
} finally {
aiLoading.value = false
}
}
/** 一键应用全部 AI 建议:覆盖分类并追加标签 */
async function handleAiApply() {
if (aiSuggestions.value.length === 0) {
return
}
aiApplying.value = true
try {
const payload = aiSuggestions.value.map((s) => ({
transactionId: s.transactionId,
categoryId: s.categoryId,
tagIds: s.tagIds || [],
}))
const res: any = await applyAiSuggestions(payload)
message.success(t('ai.applySuccess', { count: res.data }))
showAiModal.value = false
await fetchData()
} catch (e: any) {
message.error(e.message || t('ai.applyFailed'))
} finally {
aiApplying.value = false
}
}
/** 打开 AI 记账弹窗 */
function handleAiRecordOpen() {
if (!aiEnabled.value) {
message.warning(t('ai.needConfig'))
return
}
aiRecordText.value = ''
showAiRecordModal.value = true
}
/** 解析自然语言记账并预填新增表单 */
async function handleAiRecordParse() {
const text = aiRecordText.value.trim()
if (!text) {
message.warning(t('ai.record.emptyInput'))
return
}
aiRecording.value = true
try {
const res: any = await parseRecordByAi(text)
const r: AiRecordResult = res.data
editingId.value = null
form.value = {
categoryId: r.categoryId ?? null,
type: r.type || 'EXPENSE',
amount: r.amount ?? null,
paymentMethod: r.paymentMethod || '',
remark: r.remark || '',
transactionTime: r.transactionTime ? new Date(r.transactionTime).getTime() : Date.now(),
includeInStats: r.includeInStats ?? true,
tagIds: r.tagIds || [],
}
showAiRecordModal.value = false
showModal.value = true
if (r.summary) {
message.info(r.summary)
}
} catch (e: any) {
message.error(e.message || t('ai.record.failed'))
} finally {
aiRecording.value = false
}
}
/** 请求 AI 解析自然语言查询 */
async function handleAiQuery() {
if (!aiEnabled.value) {
message.warning(t('ai.needConfig'))
return
}
const text = aiQueryText.value.trim()
if (!text) {
message.warning(t('ai.query.emptyInput'))
return
}
recordAiQueryHistory()
aiQuerying.value = true
try {
const res: any = await queryByAi(text)
aiQueryResult.value = res.data
showAiQueryModal.value = true
} catch (e: any) {
message.error(e.message || t('ai.query.failed'))
} finally {
aiQuerying.value = false
}
}
/** 将 AI 解析结果应用到筛选条件并查询 */
function applyAiQuery() {
const r = aiQueryResult.value
if (!r) return
filterType.value = r.types || []
const categoryIds = [...(r.categoryIds || [])]
if (r.uncategorized) categoryIds.push(0)
filterCategory.value = categoryIds
filterPaymentMethod.value = r.paymentMethods || []
filterDateRange.value = r.startTime && r.endTime
? [new Date(r.startTime).getTime(), new Date(r.endTime).getTime()]
: null
filterIncludeInStats.value = r.includeInStats == null ? null : (r.includeInStats ? 'true' : 'false')
filterMinAmount.value = r.minAmount ?? null
filterMaxAmount.value = r.maxAmount ?? null
filterRemark.value = r.remark || ''
filterTag.value = r.tagIds || []
showAiQueryModal.value = false
handleSearch()
message.success(t('ai.query.applied'))
}
/** 解析结果是否为空(无任何可用筛选条件) */
const aiQueryEmpty = computed(() => {
const r = aiQueryResult.value
if (!r) return true
return (r.types?.length ?? 0) === 0
&& (r.categoryIds?.length ?? 0) === 0
&& !r.uncategorized
&& (r.tagIds?.length ?? 0) === 0
&& (r.paymentMethods?.length ?? 0) === 0
&& !r.startTime
&& r.minAmount == null
&& r.maxAmount == null
&& !r.remark
&& r.includeInStats == null
})
/** 格式化解析结果中的时间用于展示 */
function formatAiDate(value: string | null | undefined) {
return value ? value.replace('T', ' ').substring(0, 16) : ''
}
/** AI 查询示例(从 i18n 拆分为数组) */
const queryExamples = computed(() => t('ai.query.examples').split('|'))
/** AI 查询示例下拉选项 */
const exampleOptions = computed(() => queryExamples.value.map((example) => ({ label: example, key: example })))
/** 选择示例后填入输入框 */
function handleExampleSelect(key: string) {
aiQueryText.value = key
}
/** 从 localStorage 读取 AI 查询历史,忽略损坏数据 */
function loadAiQueryHistory() {
try {
const raw = localStorage.getItem(AI_QUERY_HISTORY_KEY)
const parsed = raw ? JSON.parse(raw) : []
aiQueryHistory.value = Array.isArray(parsed)
? parsed.filter((item) => typeof item === 'string' && item.trim()).slice(0, AI_QUERY_HISTORY_MAX)
: []
} catch (e) {
aiQueryHistory.value = []
}
}
/** 记录一次 AI 查询文本:去重(忽略大小写)、最新在前、上限 10 条 */
function recordAiQueryHistory() {
const value = aiQueryText.value.trim()
if (!value) return
const rest = aiQueryHistory.value.filter((item) => item.toLowerCase() !== value.toLowerCase())
aiQueryHistory.value = [value, ...rest].slice(0, AI_QUERY_HISTORY_MAX)
try {
localStorage.setItem(AI_QUERY_HISTORY_KEY, JSON.stringify(aiQueryHistory.value))
} catch (e) {
// 忽略写入失败
}
}
/** 清空 AI 查询历史 */
function clearAiQueryHistory() {
aiQueryHistory.value = []
try {
localStorage.removeItem(AI_QUERY_HISTORY_KEY)
} catch (e) {
// 忽略删除失败
}
}
// AI 查询历史下拉选项:按当前输入过滤历史项(忽略大小写),历史非空时末尾追加“清空历史”
const aiQueryOptions = computed<AutoCompleteOption[]>(() => {
const keyword = aiQueryText.value.trim().toLowerCase()
const options: AutoCompleteOption[] = aiQueryHistory.value
.filter((item) => item.toLowerCase().includes(keyword))
.map((item) => ({ label: item, value: item }))
if (aiQueryHistory.value.length > 0) {
options.push({ label: CLEAR_AI_QUERY_HISTORY, value: CLEAR_AI_QUERY_HISTORY })
}
return options
})
/** 输入同步:清空项执行清空,其余写入输入框(NAutoComplete 回调传入的是选项 label) */
function handleAiQueryInput(value: string | null) {
if (value === CLEAR_AI_QUERY_HISTORY) {
clearAiQueryHistory()
return
}
aiQueryText.value = value || ''
}
/** 选中历史项:填入并立即发起查询 */
function handleAiQuerySelect(value: string) {
if (value === CLEAR_AI_QUERY_HISTORY) {
clearAiQueryHistory()
return
}
aiQueryText.value = value
handleAiQuery()
}
/** 回车时发起查询(阻止 NAutoComplete 内部选中建议项) */
function handleAiQueryKeydown(e: KeyboardEvent) {
if (e.key === 'Enter') {
e.stopPropagation()
handleAiQuery()
}
}
/** 聚焦即展示历史下拉 */
function showAiQueryMenu() {
return true
}
/** 渲染下拉选项,清空项使用弱化样式与图标 */
function renderAiQueryLabel(option: AutoCompleteOption) {
if (String(option.value) === CLEAR_AI_QUERY_HISTORY) {
return h('span', { style: { display: 'flex', alignItems: 'center', gap: '4px', color: '#999' } }, [
h(NIcon, { size: 16 }, { default: () => h(CloseCircleOutline) }),
t('ai.query.clearHistory'),
])
}
return option.label as string
}
/** 将时间戳格式化为 yyyy-MM-dd */
function formatDateLabel(ts: number) {
const d = new Date(ts)
const pad = (n: number) => String(n).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`
}
/** 已激活的筛选条件标签(可单独移除) */
const activeFilterTags = computed(() => {
const tags: { key: string; label: string; clear: () => void }[] = []
filterType.value.forEach((v) => {
tags.push({
key: `type:${v}`,
label: v === 'INCOME' ? t('common.types.income') : t('common.types.expense'),
clear: () => { filterType.value = filterType.value.filter((x) => x !== v) },
})
})
filterCategory.value.forEach((id) => {
const name = id === 0
? t('transaction.uncategorized')
: categories.value.find((c) => c.value === id)?.label || String(id)
tags.push({
key: `category:${id}`,
label: name,
clear: () => { filterCategory.value = filterCategory.value.filter((x) => x !== id) },
})
})
if (filterDateRange.value) {
tags.push({
key: 'date',
label: `${formatDateLabel(filterDateRange.value[0])} ~ ${formatDateLabel(filterDateRange.value[1])}`,
clear: () => { filterDateRange.value = null },
})
}
if (filterRemark.value.trim()) {
tags.push({
key: 'remark',
label: `${t('transaction.column.remark')}: ${filterRemark.value.trim()}`,
clear: () => { filterRemark.value = '' },
})
}
filterPaymentMethod.value.forEach((v) => {
tags.push({
key: `payment:${v}`,
label: getPaymentLabel(v),
clear: () => { filterPaymentMethod.value = filterPaymentMethod.value.filter((x) => x !== v) },
})
})
filterTag.value.forEach((id) => {
const name = allTags.value.find((tag) => tag.id === id)?.name || String(id)
tags.push({
key: `tag:${id}`,
label: name,
clear: () => { filterTag.value = filterTag.value.filter((x) => x !== id) },
})
})
if (filterIncludeInStats.value !== null) {
tags.push({
key: 'includeInStats',
label: filterIncludeInStats.value === 'true' ? t('transaction.includeFilter.yes') : t('transaction.includeFilter.no'),
clear: () => { filterIncludeInStats.value = null },
})
}
if (filterMinAmount.value != null || filterMaxAmount.value != null) {
tags.push({
key: 'amount',
label: `${t('transaction.amountRange')}: ${filterMinAmount.value ?? 0} ~ ${filterMaxAmount.value ?? '∞'}`,
clear: () => { filterMinAmount.value = null; filterMaxAmount.value = null },
})
}
return tags
})
/** 移除单个筛选条件后自动重查 */
function applyTagClear(tag: { clear: () => void }) {
tag.clear()
handleSearch()
}
/** AI 建议行:合并交易记录信息用于展示 */
const aiRows = computed(() =>
aiSuggestions.value.map((s) => ({
...s,
transaction: transactions.value.find((t) => t.id === s.transactionId) || null,
})),
)
/** 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 || '-'),
])
},
},
{ 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 } },
])
/** 打开批量设置分类弹窗 */
function handleBatchOpen() {
if (transactions.value.length === 0) {
@@ -678,6 +1191,9 @@ async function handleExport() {
// 组件挂载时加载分类和交易记录
onMounted(() => {
loadFilterPrefs()
loadRemarkHistory()
loadAiQueryHistory()
loadAiConfig()
fetchCategories()
fetchTags()
fetchData()
@@ -686,22 +1202,63 @@ onMounted(() => {
<template>
<div>
<!-- 顶部:标题和新增按钮 -->
<NSpace justify="space-between" style="margin-bottom: 16px;">
<!-- 顶部:标题和操作按钮 -->
<NSpace justify="space-between" align="center" style="margin-bottom: 16px;" :wrap="true">
<h2 style="margin: 0;">{{ t('transaction.title') }}</h2>
<NSpace align="center">
<NSpace align="center" :wrap="true">
<span style="color: #999; font-size: 13px;">{{ t('transaction.sortLabel') }}</span>
<NSelect v-model:value="sortValue" :options="sortOptions" style="width: 170px;" />
<NDivider vertical style="margin: 0 4px;" />
<NButton @click="handleBatchOpen">{{ t('transaction.batchSet') }}</NButton>
<NButton @click="showImportModal = true">{{ t('transaction.importBill') }}</NButton>
<NButton @click="handleExport">{{ t('transaction.exportExcel') }}</NButton>
<NButton v-if="aiEnabled" @click="handleAiRecordOpen">
<template #icon>
<NIcon :component="SparklesOutline" />
</template>
{{ t('ai.record.button') }}
</NButton>
<NButton type="primary" @click="handleAdd">{{ t('transaction.addRecord') }}</NButton>
</NSpace>
</NSpace>
<!-- 筛选条件区域:常用条件常驻,其余收进「更多筛选」 -->
<!-- 查询与筛选 -->
<NCard :bordered="false" style="margin-bottom: 16px;">
<NSpace align="center" :wrap="true" :size="[12, 12]">
<!-- AI 智能查询(仅启用时显示) -->
<template v-if="aiEnabled">
<NSpace align="center" :wrap="true" :size="[12, 8]">
<NAutoComplete
:value="aiQueryText"
:options="aiQueryOptions"
:get-show="showAiQueryMenu"
:render-label="renderAiQueryLabel"
:input-props="{ onKeydown: handleAiQueryKeydown }"
clearable
:placeholder="t('ai.query.placeholder')"
style="width: 360px;"
@update:value="handleAiQueryInput"
@select="handleAiQuerySelect"
>
<template #prefix>
<NIcon :component="SparklesOutline" />
</template>
</NAutoComplete>
<NButton type="primary" :loading="aiQuerying" @click="handleAiQuery">{{ t('ai.query.button') }}</NButton>
<NDropdown trigger="click" :options="exampleOptions" @select="handleExampleSelect">
<NButton text type="primary" size="small">
<template #icon>
<NIcon :component="BulbOutline" />
</template>
{{ t('transaction.exampleLabel') }}
</NButton>
</NDropdown>
</NSpace>
<NDivider style="margin: 12px 0;" />
</template>
<!-- 常用筛选条件 -->
<NSpace align="center" :wrap="true" :size="[12, 8]">
<NSelect
v-model:value="filterType"
:options="typeOptions"
@@ -729,12 +1286,18 @@ onMounted(() => {
clearable
style="width: 260px;"
/>
<NInput
v-model:value="filterRemark"
<NAutoComplete
:value="filterRemark"
:options="remarkOptions"
:get-show="showRemarkMenu"
:render-label="renderRemarkLabel"
:input-props="{ onKeydown: handleRemarkKeydown }"
clearable
:placeholder="t('transaction.column.remark')"
style="width: 180px;"
@keyup.enter="handleSearch"
@update:value="handleRemarkInput"
@select="handleRemarkSelect"
@blur="handleRemarkBlur"
/>
<NPopover v-model:show="showMoreFilter" trigger="click" placement="bottom-start">
<template #trigger>
@@ -811,6 +1374,23 @@ onMounted(() => {
<NButton type="primary" @click="handleSearch">{{ t('transaction.search') }}</NButton>
<NButton @click="handleReset">{{ t('transaction.reset') }}</NButton>
</NSpace>
<!-- 已激活的筛选条件 -->
<div v-if="activeFilterTags.length > 0" class="active-filters">
<NSpace align="center" :wrap="true" :size="[8, 8]">
<span style="color: #999; font-size: 12px;">{{ t('transaction.activeFilters') }}</span>
<NTag
v-for="tag in activeFilterTags"
:key="tag.key"
size="small"
closable
@close="applyTagClear(tag)"
>
{{ tag.label }}
</NTag>
<NButton text size="tiny" type="primary" @click="handleReset">{{ t('transaction.clearAll') }}</NButton>
</NSpace>
</div>
</NCard>
<!-- 表格信息栏(右上角) -->
@@ -897,6 +1477,9 @@ onMounted(() => {
{{ t('transaction.batchScopeHint', { count: transactions.length }) }}
</span>
</NFormItem>
<NFormItem v-if="aiEnabled" :label="t('ai.suggestButton')">
<NButton :loading="aiLoading" @click="handleAiSuggest">{{ t('ai.suggestButton') }}</NButton>
</NFormItem>
<NFormItem :label="t('transaction.expenseCategory')">
<NSelect
v-model:value="batchExpenseCategoryId"
@@ -929,6 +1512,99 @@ onMounted(() => {
<NButton type="primary" block :loading="batching" @click="handleBatchSubmit">{{ t('common.confirm') }}</NButton>
</NForm>
</NModal>
<!-- AI 建议弹窗(一键应用分类与标签建议) -->
<NModal 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
:columns="aiColumns"
:data="aiRows"
:max-height="420"
:row-key="(row) => row.transactionId"
size="small"
:bordered="false"
/>
<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>
</NSpace>
</NSpace>
</NModal>
<!-- AI 自然语言查询解析结果弹窗 -->
<NModal 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>
<NDescriptions v-else :column="1" label-placement="left" bordered size="small">
<NDescriptionsItem v-if="aiQueryResult?.types?.length" :label="t('ai.query.type')">
<NSpace :size="6">
<NTag v-for="tp in aiQueryResult.types" :key="tp" size="small" :type="tp === 'INCOME' ? 'success' : 'error'">
{{ tp === 'INCOME' ? t('common.types.income') : t('common.types.expense') }}
</NTag>
</NSpace>
</NDescriptionsItem>
<NDescriptionsItem v-if="aiQueryResult?.categoryNames?.length || aiQueryResult?.uncategorized" :label="t('ai.query.category')">
<NSpace :size="6">
<NTag v-for="c in aiQueryResult?.categoryNames || []" :key="c" size="small">{{ c }}</NTag>
<NTag v-if="aiQueryResult?.uncategorized" size="small">{{ t('transaction.uncategorized') }}</NTag>
</NSpace>
</NDescriptionsItem>
<NDescriptionsItem v-if="aiQueryResult?.tagNames?.length" :label="t('ai.query.tag')">
<NSpace :size="6">
<NTag v-for="tg in aiQueryResult.tagNames" :key="tg" size="small">{{ tg }}</NTag>
</NSpace>
</NDescriptionsItem>
<NDescriptionsItem v-if="aiQueryResult?.paymentMethods?.length" :label="t('ai.query.payment')">
<NSpace :size="6">
<NTag v-for="pm in aiQueryResult.paymentMethods" :key="pm" size="small">{{ getPaymentLabel(pm) }}</NTag>
</NSpace>
</NDescriptionsItem>
<NDescriptionsItem v-if="aiQueryResult?.startTime && aiQueryResult?.endTime" :label="t('ai.query.dateRange')">
{{ formatAiDate(aiQueryResult.startTime) }} ~ {{ formatAiDate(aiQueryResult.endTime) }}
</NDescriptionsItem>
<NDescriptionsItem
v-if="aiQueryResult && (aiQueryResult.minAmount != null || aiQueryResult.maxAmount != null)"
:label="t('ai.query.amountRange')"
>
{{ aiQueryResult.minAmount ?? 0 }} ~ {{ aiQueryResult.maxAmount ?? '∞' }}
</NDescriptionsItem>
<NDescriptionsItem v-if="aiQueryResult?.remark" :label="t('ai.query.remark')">{{ aiQueryResult.remark }}</NDescriptionsItem>
<NDescriptionsItem v-if="aiQueryResult && aiQueryResult.includeInStats != null" :label="t('ai.query.includeInStats')">
{{ aiQueryResult.includeInStats ? t('ai.query.includeYes') : t('ai.query.includeNo') }}
</NDescriptionsItem>
<NDescriptionsItem v-if="aiQueryResult?.unmatched?.length" :label="t('ai.query.unmatched')">
<NSpace :size="6">
<NTag v-for="u in aiQueryResult.unmatched" :key="u" size="small" type="warning">{{ u }}</NTag>
</NSpace>
</NDescriptionsItem>
</NDescriptions>
<NSpace justify="end">
<NButton @click="showAiQueryModal = false">{{ t('common.cancel') }}</NButton>
<NButton type="primary" :disabled="aiQueryEmpty" @click="applyAiQuery">{{ t('ai.query.apply') }}</NButton>
</NSpace>
</NSpace>
</NModal>
<!-- AI 智能记账弹窗 -->
<NModal 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
v-model:value="aiRecordText"
type="textarea"
:autosize="{ minRows: 3, maxRows: 6 }"
:placeholder="t('ai.record.placeholder')"
/>
<NSpace justify="end">
<NButton @click="showAiRecordModal = false">{{ t('common.cancel') }}</NButton>
<NButton type="primary" :loading="aiRecording" @click="handleAiRecordParse">{{ t('ai.record.parse') }}</NButton>
</NSpace>
</NSpace>
</NModal>
</div>
</template>
@@ -949,4 +1625,9 @@ onMounted(() => {
color: #999;
font-size: 12px;
}
.active-filters {
margin-top: 12px;
padding-top: 12px;
border-top: 1px solid rgba(0, 0, 0, 0.06);
}
</style>