refactor: 前端 SSE 鉴权改造与公共逻辑抽取,修复统计分组空指针

- SSE 由 EventSource 改为 fetch 流式读取(新增 utils/sse.ts),token 走 Authorization 请求头,避免令牌经 URL 泄露到访问日志
- 抽取 useKeywordHistory,统一备注关键词与 AI 查询历史的读写、下拉与清空
- 抽取 renderCategoryLabel 至 utils/render.ts,消除多处重复实现
- 导出改用统一 request 实例(自动携带鉴权头),并将 blob 错误体解析为业务异常
- 新增 untagged 筛选参数;AI 智能查询改为弹窗入口并补充三语文案
- 分类/标签/邮箱/交易表单补充提交 loading,避免重复提交
- 交易时间改用本地格式化,修复 toISOString 造成的时区偏移
- 移除 Naive UI 全局注册;修正 NLog 的 rows 属性为 lines
- 修复未分类记录分组时 categoryMap.get(null) 抛出的 NPE
This commit is contained in:
2026-09-20 15:09:35 +08:00
parent 5fb3d78ce0
commit 0a8811534b
17 changed files with 476 additions and 375 deletions
+118 -233
View File
@@ -3,14 +3,16 @@
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, CloseCircleOutline, SparklesOutline, BulbOutline } from '@vicons/ionicons5'
import type { DataTableColumns, AutoCompleteOption } from 'naive-ui'
import { EyeOutline, EyeOffOutline, SparklesOutline, BulbOutline } 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 { 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 { renderCategoryLabel } from '../utils/render'
import { useKeywordHistory } from '../composables/useKeywordHistory'
import BillImportModal from './BillImportModal.vue'
const message = useMessage() // 消息提示
@@ -70,9 +72,9 @@ const totalPages = computed(() => Math.max(1, Math.ceil(total.value / pageSize.v
const pageSummary = computed(() => {
let income = 0
let expense = 0
for (const t of transactions.value) {
const amount = Number(t.amount) || 0
if (t.type === 'INCOME') income += amount
for (const tx of transactions.value) {
const amount = Number(tx.amount) || 0
if (tx.type === 'INCOME') income += amount
else expense += amount
}
return { income, expense, balance: income - expense }
@@ -81,15 +83,16 @@ const pageSummary = computed(() => {
const pageExcludedSummary = computed(() => {
let income = 0
let expense = 0
for (const t of transactions.value) {
if (t.includeInStats) continue
const amount = Number(t.amount) || 0
if (t.type === 'INCOME') income += amount
for (const tx of transactions.value) {
if (tx.includeInStats) continue
const amount = Number(tx.amount) || 0
if (tx.type === 'INCOME') income += amount
else expense += amount
}
return { income, expense }
})
const loading = ref(false) // 列表加载状态
const submitting = ref(false) // 表单提交状态
const showModal = ref(false) // 新增/编辑弹窗显示状态
const showImportModal = ref(false) // 账单导入弹窗显示状态
const showBatchModal = ref(false) // 批量设置分类弹窗显示状态
@@ -130,7 +133,8 @@ 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 aiTagSuggestLoading = ref(false) // 编辑弹窗内「推荐已有标签」请求状态
const aiTagNewLoading = ref(false) // 编辑弹窗内「生成新标签」请求状态
const aiTagSaving = ref(false) // 新标签保存状态
// AI 生成但尚未保存的新标签,需用户确认后再落库
const pendingTag = ref<{ name: string; color: string } | null>(null)
@@ -140,12 +144,15 @@ const checkedAiRowKeys = ref<number[]>([]) // AI 建议弹窗中已勾选的代
const aiQueryText = ref('') // AI 自然语言查询输入
const aiQuerying = ref(false) // AI 查询解析状态
const aiQueryResult = ref<AiQueryResult | null>(null) // AI 查询解析结果
// AI 查询历史存储键与上限
const showAiQueryModal = ref(false) // 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 查询历史(最近使用,最新在前)
// AI 查询历史(最近使用,最新在前)
const aiQueryHistory = useKeywordHistory({
storageKey: AI_QUERY_HISTORY_KEY,
text: aiQueryText,
clearLabel: () => t('ai.query.clearHistory'),
})
const showAiRecordModal = ref(false) // AI 记账弹窗显示状态
const aiRecordText = ref('') // AI 记账输入
const aiRecording = ref(false) // AI 记账解析状态
@@ -158,8 +165,8 @@ 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 hasIncomeRecords = computed(() => transactions.value.some((tx) => tx.type === 'INCOME'))
const hasExpenseRecords = computed(() => transactions.value.some((tx) => tx.type === 'EXPENSE'))
// 批量设置计入统计选项
const batchStatsOptions = computed(() => [
@@ -206,12 +213,14 @@ 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 remarkHistory = useKeywordHistory({
storageKey: REMARK_HISTORY_KEY,
text: filterRemark,
clearLabel: () => t('transaction.remarkHistoryClear'),
})
const filterMinAmount = ref<number | null>(null) // 最小金额筛选
const filterMaxAmount = ref<number | null>(null) // 最大金额筛选
const filterTag = ref<number[]>([]) // 标签筛选(多选)
@@ -241,7 +250,7 @@ const categoryFilterOptions = computed(() => [
])
// 标签筛选项
const tagFilterOptions = computed(() => allTags.value.map((t) => ({ label: t.name, value: t.id })))
const tagFilterOptions = computed(() => allTags.value.map((tag) => ({ label: tag.name, value: tag.id })))
// 标签筛选下拉项:额外提供「无标签」选项(仅筛选使用,编辑/批量选择不包含)
const tagFilterSelectOptions = computed(() => [
@@ -326,67 +335,10 @@ 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()
if (value === remarkHistory.clearValue) {
remarkHistory.clear()
return
}
filterRemark.value = value
@@ -395,34 +347,18 @@ function handleRemarkSelect(value: string) {
/** 失焦时记录当前备注关键词 */
function handleRemarkBlur() {
recordRemarkKeyword()
remarkHistory.record()
}
/** 回车时记录关键词并搜索(阻止 NAutoComplete 内部选中建议项,保持原回车搜索语义) */
function handleRemarkKeydown(e: KeyboardEvent) {
if (e.key === 'Enter') {
e.stopPropagation()
recordRemarkKeyword()
remarkHistory.record()
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)
@@ -432,12 +368,11 @@ function formatTimestamp(ts: number, isEnd = false): string {
return `${date}T${time}`
}
/** 分类下拉项渲染:图标 + 名称(下拉选项与已选项均生效) */
function renderCategoryLabel(option: any) {
return h('span', null, [
option.icon ? h('span', { style: { marginRight: '6px' } }, option.icon) : null,
option.label,
])
/** 将时间戳格式化为本地日期时间字符串(YYYY-MM-DDTHH:mm:ss),避免 UTC 转换导致时区偏移 */
function formatLocalDateTime(ts: number): string {
const d = new Date(ts)
const pad = (n: number) => String(n).padStart(2, '0')
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}`
}
/** 分类已选项(多选标签)渲染:图标 + 名称 */
@@ -689,7 +624,7 @@ function handleEdit(row: TransactionItem) {
remark: row.remark || '',
transactionTime: new Date(row.transactionTime).getTime(),
includeInStats: row.includeInStats ?? true,
tagIds: (row.tags || []).map((t) => t.id),
tagIds: (row.tags || []).map((tag) => tag.id),
}
pendingCategory.value = null
pendingTag.value = null
@@ -713,10 +648,11 @@ async function handleSubmit() {
amount: form.value.amount!,
paymentMethod: form.value.paymentMethod || undefined,
remark: form.value.remark || undefined,
transactionTime: new Date(form.value.transactionTime).toISOString().slice(0, 19),
transactionTime: formatLocalDateTime(form.value.transactionTime),
includeInStats: form.value.includeInStats,
tagIds: form.value.tagIds,
}
submitting.value = true
try {
if (editingId.value) {
await updateTransaction(editingId.value, data)
@@ -729,6 +665,8 @@ async function handleSubmit() {
await fetchData()
} catch (e: any) {
message.error(e.message || t('common.operationFailed'))
} finally {
submitting.value = false
}
}
@@ -840,7 +778,7 @@ async function handleAiSuggest() {
}
aiLoading.value = true
try {
const ids = transactions.value.map((t) => t.id)
const ids = transactions.value.map((tx) => tx.id)
const res: any = await suggestByAi(ids, aiScope.value)
aiSuggestions.value = res.data || []
if (aiSuggestions.value.length === 0) {
@@ -951,7 +889,7 @@ async function handleSaveNewCategory() {
/** 编辑弹窗内 AI 推荐已有标签:基于表单当前内容,追加到已选标签 */
async function handleAiTagSuggest() {
aiTagLoading.value = true
aiTagSuggestLoading.value = true
try {
const res: any = await suggestOneByAi({
type: form.value.type,
@@ -970,7 +908,7 @@ async function handleAiTagSuggest() {
} catch (e: any) {
message.error(e.message || t('ai.suggestFailed'))
} finally {
aiTagLoading.value = false
aiTagSuggestLoading.value = false
}
}
@@ -980,7 +918,7 @@ async function handleAiNewTag() {
message.warning(t('ai.newTag.needInfo'))
return
}
aiTagLoading.value = true
aiTagNewLoading.value = true
try {
const res: any = await parseTagByAi(buildCategoryPrompt())
const data = res.data
@@ -992,7 +930,7 @@ async function handleAiNewTag() {
} catch (e: any) {
message.error(e.message || t('ai.newTag.failed'))
} finally {
aiTagLoading.value = false
aiTagNewLoading.value = false
}
}
@@ -1135,6 +1073,15 @@ async function handleAiRecordParse() {
}
}
/** 打开 AI 智能查询弹窗 */
function handleAiQueryOpen() {
if (!aiEnabled.value) {
message.warning(t('ai.needConfig'))
return
}
showAiQueryModal.value = true
}
/** 请求 AI 解析自然语言查询 */
async function handleAiQuery() {
if (!aiEnabled.value) {
@@ -1146,7 +1093,7 @@ async function handleAiQuery() {
message.warning(t('ai.query.emptyInput'))
return
}
recordAiQueryHistory()
aiQueryHistory.record()
aiQuerying.value = true
try {
const res: any = await queryByAi(text)
@@ -1158,6 +1105,7 @@ async function handleAiQuery() {
}
// 直接应用解析结果并搜索,无需二次确认
applyAiQuery()
showAiQueryModal.value = false
} catch (e: any) {
message.error(e.message || t('ai.query.failed'))
} finally {
@@ -1214,67 +1162,10 @@ 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()
if (value === aiQueryHistory.clearValue) {
aiQueryHistory.clear()
return
}
aiQueryText.value = value
@@ -1289,22 +1180,6 @@ function handleAiQueryKeydown(e: KeyboardEvent) {
}
}
/** 聚焦即展示历史下拉 */
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)
@@ -1399,7 +1274,7 @@ function applyTagClear(tag: { clear: () => void }) {
const aiRows = computed(() =>
aiSuggestions.value.map((s) => ({
...s,
transaction: transactions.value.find((t) => t.id === s.transactionId) || null,
transaction: transactions.value.find((tx) => tx.id === s.transactionId) || null,
})),
)
@@ -1529,7 +1404,7 @@ async function handleBatchSubmit() {
}
batching.value = true
try {
const ids = transactions.value.map((t) => t.id)
const ids = transactions.value.map((tx) => tx.id)
if (hasCategoryOrStats) {
await batchUpdateCategory({
ids,
@@ -1607,8 +1482,8 @@ async function doExport() {
// 组件挂载时加载分类和交易记录
onMounted(() => {
loadFilterPrefs()
loadRemarkHistory()
loadAiQueryHistory()
remarkHistory.load()
aiQueryHistory.load()
loadAiConfig()
fetchCategories()
fetchTags()
@@ -1644,39 +1519,6 @@ onMounted(() => {
<!-- 查询与筛选 -->
<NCard :bordered="false" style="margin-bottom: 16px;">
<!-- 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
@@ -1708,14 +1550,14 @@ onMounted(() => {
/>
<NAutoComplete
:value="filterRemark"
:options="remarkOptions"
:get-show="showRemarkMenu"
:render-label="renderRemarkLabel"
:options="remarkHistory.options.value"
:get-show="remarkHistory.showMenu"
:render-label="remarkHistory.renderLabel"
:input-props="{ onKeydown: handleRemarkKeydown }"
clearable
:placeholder="t('transaction.column.remark')"
style="width: 180px;"
@update:value="handleRemarkInput"
@update:value="remarkHistory.handleInput"
@select="handleRemarkSelect"
@blur="handleRemarkBlur"
/>
@@ -1790,6 +1632,12 @@ onMounted(() => {
</NPopover>
<NButton type="primary" @click="handleSearch">{{ t('transaction.search') }}</NButton>
<NButton @click="handleReset">{{ t('transaction.reset') }}</NButton>
<NButton v-if="aiEnabled" @click="handleAiQueryOpen">
<template #icon>
<NIcon :component="SparklesOutline" />
</template>
{{ t('ai.query.entry') }}
</NButton>
</NSpace>
<!-- 已激活的筛选条件 -->
@@ -1893,8 +1741,8 @@ onMounted(() => {
: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>
<NButton v-if="aiEnabled" size="small" :loading="aiTagSuggestLoading" @click="handleAiTagSuggest">{{ t('ai.pickButton') }}</NButton>
<NButton v-if="aiEnabled" size="small" :loading="aiTagNewLoading" @click="handleAiNewTag">{{ t('ai.newTag.button') }}</NButton>
</div>
</NFormItem>
<!-- AI 生成的新标签确认条:用户确认后才保存 -->
@@ -1908,7 +1756,7 @@ onMounted(() => {
<NSwitch v-model:value="form.includeInStats" />
<span style="color: #999; margin-left: 8px; font-size: 12px;">{{ t('transaction.includeInStatsHint') }}</span>
</NFormItem>
<NButton type="primary" block @click="handleSubmit">{{ t('common.confirm') }}</NButton>
<NButton type="primary" block :loading="submitting" @click="handleSubmit">{{ t('common.confirm') }}</NButton>
</NForm>
</NModal>
@@ -2005,6 +1853,43 @@ onMounted(() => {
</NSpace>
</NSpace>
</NModal>
<!-- AI 智能查询弹窗 -->
<NModal v-if="aiEnabled" v-model:show="showAiQueryModal" 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="aiQueryHistory.options.value"
:get-show="aiQueryHistory.showMenu"
:render-label="aiQueryHistory.renderLabel"
:input-props="{ onKeydown: handleAiQueryKeydown }"
clearable
:placeholder="t('ai.query.placeholder')"
style="width: 100%;"
@update:value="aiQueryHistory.handleInput"
@select="handleAiQuerySelect"
>
<template #prefix>
<NIcon :component="SparklesOutline" />
</template>
</NAutoComplete>
<NSpace justify="space-between" align="center">
<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>
<NButton @click="showAiQueryModal = false">{{ t('common.cancel') }}</NButton>
<NButton type="primary" :loading="aiQuerying" @click="handleAiQuery">{{ t('ai.query.button') }}</NButton>
</NSpace>
</NSpace>
</NSpace>
</NModal>
</div>
</template>