diff --git a/frontend/src/api/ai.ts b/frontend/src/api/ai.ts new file mode 100644 index 0000000..ac86b72 --- /dev/null +++ b/frontend/src/api/ai.ts @@ -0,0 +1,129 @@ +// AI 智能建议相关 API 接口:配置读写、连通性测试、建议生成与应用 +import request from './request' + +/** AI 配置数据结构(API Key 已脱敏) */ +export interface AiConfig { + enabled: boolean + apiKey: string | null + hasApiKey: boolean + baseUrl: string + model: string +} + +/** 单条 AI 建议 */ +export interface AiSuggestion { + transactionId: number + categoryId: number | null + categoryName: string | null + tagIds: number[] + tagNames: string[] + reason: string +} + +/** 获取当前用户的 AI 配置 */ +export function getAiConfig() { + return request.get('/ai/config') +} + +/** 保存 AI 配置,apiKey 留空表示不修改原值 */ +export function saveAiConfig(data: { enabled: boolean; apiKey?: string; baseUrl?: string; model?: string }) { + return request.put('/ai/config', data) +} + +/** 测试 DeepSeek 连通性(耗时较长,单独放宽超时) */ +export function testAiConfig() { + return request.post('/ai/config/test', {}, { timeout: 60000 }) +} + +/** 生成分类与标签建议(调用大模型,超时放宽) */ +export function suggestByAi(ids: number[]) { + return request.post('/ai/suggest', { ids }, { timeout: 120000 }) +} + +/** 一键应用建议:覆盖分类并追加标签 */ +export function applyAiSuggestions(suggestions: { transactionId: number; categoryId: number | null; tagIds: number[] }[]) { + return request.post('/ai/apply', { suggestions }) +} + +/** 自然语言查询解析结果 */ +export interface AiQueryResult { + summary: string + types: string[] + categoryIds: number[] + categoryNames: string[] + uncategorized: boolean + paymentMethods: string[] + startTime: string | null + endTime: string | null + includeInStats: boolean | null + minAmount: number | null + maxAmount: number | null + remark: string | null + tagIds: number[] + tagNames: string[] + unmatched: string[] +} + +/** 将自然语言查询解析为筛选条件 */ +export function queryByAi(query: string) { + return request.post('/ai/query', { query }, { timeout: 120000 }) +} + +/** 自然语言记账解析结果 */ +export interface AiRecordResult { + summary: string + type: string + amount: number | null + categoryId: number | null + categoryName: string | null + paymentMethod: string | null + transactionTime: string | null + remark: string | null + includeInStats: boolean | null + tagIds: number[] + tagNames: string[] + unmatched: string[] +} + +/** 将自然语言记账内容解析为交易记录 */ +export function parseRecordByAi(text: string) { + return request.post('/ai/record', { text }, { timeout: 120000 }) +} + +/** AI 使用累计汇总 */ +export interface AiUsageSummary { + totalCalls: number + successCalls: number + failedCalls: number + promptTokens: number + completionTokens: number + totalTokens: number + cachedTokens: number +} + +/** 单条 AI 使用明细 */ +export interface AiUsageItem { + id: number + type: string + model: string + transactionCount: number + promptTokens: number + completionTokens: number + totalTokens: number + cachedTokens: number + success: boolean + errorMessage: string | null + durationMs: number | null + createdAt: string +} + +/** AI 使用情况(累计汇总 + 最近明细) */ +export interface AiUsageResult { + summary: AiUsageSummary + recent: AiUsageItem[] +} + +/** 获取 AI 使用情况 */ +export function getAiUsage(limit = 20) { + return request.get('/ai/usage', { params: { limit } }) +} diff --git a/frontend/src/locales/messages.ts b/frontend/src/locales/messages.ts index e9416ad..bf07dc8 100644 --- a/frontend/src/locales/messages.ts +++ b/frontend/src/locales/messages.ts @@ -81,6 +81,7 @@ export default { transactions: { zh: '收支记录', en: 'Transactions' }, categories: { zh: '分类管理', en: 'Categories' }, tags: { zh: '标签管理', en: 'Tags' }, + aiSettings: { zh: 'AI 设置', en: 'AI Settings' }, user: { zh: '用户', en: 'User' }, logout: { zh: '退出', en: 'Sign Out' }, }, @@ -141,6 +142,9 @@ export default { amountRange: { zh: '金额区间', en: 'Amount Range' }, search: { zh: '查询', en: 'Search' }, reset: { zh: '重置', en: 'Reset' }, + activeFilters: { zh: '已激活:', en: 'Active:' }, + clearAll: { zh: '清空全部', en: 'Clear all' }, + exampleLabel: { zh: '示例', en: 'Examples' }, summary: { zh: '总计 {total} 条 · 第 {page} / {pages} 页 · 每页 {size} 条', en: 'Total {total} · Page {page} / {pages} · {size} per page' }, pageExpense: { zh: '本页支出', en: 'Page Expense' }, pageIncome: { zh: '本页收入', en: 'Page Income' }, @@ -193,6 +197,7 @@ export default { amountPlaceholder: { zh: '请输入金额', en: 'Enter amount' }, paymentPlaceholder: { zh: '选择支付方式', en: 'Select payment method' }, remarkPlaceholder: { zh: '可选备注说明', en: 'Optional remark' }, + remarkHistoryClear: { zh: '清空历史', en: 'Clear history' }, tagPlaceholder: { zh: '选择标签', en: 'Select tags' }, noRawData: { zh: '该记录没有原始账单数据', en: 'This record has no raw bill data' }, remarkHiddenSuccess: { zh: '已隐藏备注', en: 'Remark hidden' }, @@ -299,4 +304,104 @@ export default { loadCategoriesFailed: { zh: '加载分类失败', en: 'Failed to load categories' }, loadTagsFailed: { zh: '加载标签失败', en: 'Failed to load tags' }, }, + + // AI 智能建议 + ai: { + title: { zh: 'AI 智能建议', en: 'AI Suggestions' }, + intro: { zh: '配置 DeepSeek 后,可在收支记录页的「一键设置」中让 AI 根据记录内容推荐分类与标签。', en: 'After configuring DeepSeek, you can let AI recommend categories and tags for records in the "Batch Set" dialog on the Transactions page.' }, + configTitle: { zh: '模型配置', en: 'Model Configuration' }, + enabledOn: { zh: '已启用', en: 'Enabled' }, + enabledOff: { zh: '未启用', en: 'Disabled' }, + enabled: { zh: '启用 AI 建议', en: 'Enable AI suggestions' }, + enabledHint: { zh: '关闭后不会发起任何 AI 请求', en: 'No AI requests are sent when disabled' }, + apiKey: { zh: 'API Key', en: 'API Key' }, + apiKeyPlaceholder: { zh: '请输入 DeepSeek API Key', en: 'Enter your DeepSeek API Key' }, + apiKeyKeep: { zh: '已配置,留空表示不修改', en: 'Configured; leave blank to keep unchanged' }, + baseUrl: { zh: '接口地址', en: 'Base URL' }, + model: { zh: '模型', en: 'Model' }, + save: { zh: '保存', en: 'Save' }, + test: { zh: '测试连接', en: 'Test connection' }, + saveSuccess: { zh: '保存成功', en: 'Saved successfully' }, + loadFailed: { zh: '加载 AI 配置失败', en: 'Failed to load AI config' }, + testSuccess: { zh: '连接成功', en: 'Connection succeeded' }, + testFailed: { zh: '连接失败', en: 'Connection failed' }, + suggestButton: { zh: 'AI 智能建议', en: 'AI suggestions' }, + suggestTitle: { zh: 'AI 分类与标签建议', en: 'AI category & tag suggestions' }, + suggesting: { zh: '正在分析,请稍候…', en: 'Analyzing, please wait…' }, + suggestFailed: { zh: 'AI 建议生成失败', en: 'Failed to generate AI suggestions' }, + noSuggestion: { zh: 'AI 未返回有效建议', en: 'AI returned no valid suggestions' }, + applyAll: { zh: '全部应用({count})', en: 'Apply all ({count})' }, + applySuccess: { zh: '已应用 {count} 条建议', en: 'Applied {count} suggestions' }, + applyFailed: { zh: '应用建议失败', en: 'Failed to apply suggestions' }, + applyHint: { zh: '将覆盖分类并追加标签,确认后立即写入', en: 'Categories will be overwritten and tags appended; changes are written immediately.' }, + needConfig: { zh: 'AI 未启用,请先在「AI 设置」中配置', en: 'AI is not enabled. Please configure it in AI Settings first.' }, + query: { + placeholder: { zh: '用自然语言查询,如:上个月餐饮花了多少', en: 'Ask in natural language, e.g. dining expenses last month' }, + examples: { zh: '上个月餐饮|超过500的支出|待报销的支出|微信支付', en: 'Dining last month|Expenses over 500|Pending reimbursement|Paid by WeChat' }, + button: { zh: 'AI 查询', en: 'AI Query' }, + emptyInput: { zh: '请输入查询内容', en: 'Please enter a query' }, + clearHistory: { zh: '清空历史', en: 'Clear history' }, + failed: { zh: 'AI 查询解析失败', en: 'Failed to parse the query' }, + resultTitle: { zh: 'AI 解析结果', en: 'AI Query Result' }, + apply: { zh: '应用筛选', en: 'Apply filters' }, + applied: { zh: '已应用 AI 查询筛选条件', en: 'AI query filters applied' }, + noCondition: { zh: '未识别到可用的筛选条件,请换一种说法', en: 'No usable filter conditions recognized, please rephrase' }, + type: { zh: '类型', en: 'Type' }, + category: { zh: '分类', en: 'Category' }, + tag: { zh: '标签', en: 'Tag' }, + payment: { zh: '支付方式', en: 'Payment' }, + dateRange: { zh: '时间范围', en: 'Date range' }, + amountRange: { zh: '金额范围', en: 'Amount range' }, + remark: { zh: '备注', en: 'Remark' }, + includeInStats: { zh: '计入统计', en: 'In stats' }, + includeYes: { zh: '计入', en: 'Included' }, + includeNo: { zh: '不计入', en: 'Excluded' }, + unmatched: { zh: '未识别', en: 'Unmatched' }, + }, + record: { + button: { zh: '智能记账', en: 'Smart Entry' }, + title: { zh: 'AI 智能记账', en: 'AI Smart Entry' }, + hint: { zh: '用一句话描述这笔收支,AI 会帮你填好表单,确认后再保存。', en: 'Describe the transaction in one sentence; AI fills the form for you to confirm.' }, + placeholder: { zh: '例如:昨天中午吃饭 35 支付宝', en: 'e.g. Lunch yesterday 35 Alipay' }, + parse: { zh: '解析', en: 'Parse' }, + emptyInput: { zh: '请输入记账内容', en: 'Please enter the transaction description' }, + failed: { zh: 'AI 记账解析失败', en: 'Failed to parse the transaction' }, + }, + column: { + record: { zh: '记录', en: 'Record' }, + category: { zh: '建议分类', en: 'Suggested category' }, + tags: { zh: '建议标签', en: 'Suggested tags' }, + reason: { zh: '理由', en: 'Reason' }, + }, + usage: { + title: { zh: '使用情况', en: 'Usage' }, + recentTitle: { zh: '最近调用记录', en: 'Recent calls' }, + refresh: { zh: '刷新', en: 'Refresh' }, + totalCalls: { zh: '调用次数', en: 'Calls' }, + successCalls: { zh: '成功', en: 'Succeeded' }, + failedCalls: { zh: '失败', en: 'Failed' }, + promptTokens: { zh: '输入 Token', en: 'Prompt tokens' }, + completionTokens: { zh: '输出 Token', en: 'Completion tokens' }, + totalTokens: { zh: '总 Token', en: 'Total tokens' }, + cachedTokens: { zh: '缓存命中', en: 'Cached tokens' }, + success: { zh: '成功', en: 'Success' }, + failed: { zh: '失败', en: 'Failed' }, + loadFailed: { zh: '加载使用情况失败', en: 'Failed to load usage' }, + empty: { zh: '暂无调用记录', en: 'No usage records yet' }, + type: { + suggest: { zh: '生成建议', en: 'Suggest' }, + test: { zh: '测试连接', en: 'Test' }, + }, + column: { + time: { zh: '时间', en: 'Time' }, + type: { zh: '类型', en: 'Type' }, + count: { zh: '分析条数', en: 'Records' }, + promptTokens: { zh: '输入', en: 'Prompt' }, + completionTokens: { zh: '输出', en: 'Completion' }, + totalTokens: { zh: '总计', en: 'Total' }, + duration: { zh: '耗时', en: 'Duration' }, + status: { zh: '状态', en: 'Status' }, + }, + }, + }, } diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index 9edcecb..7d74ec8 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -41,6 +41,11 @@ const router = createRouter({ name: 'Tags', component: () => import('../views/TagManage.vue'), // 标签管理 }, + { + path: 'ai-settings', + name: 'AiSettings', + component: () => import('../views/AiSettings.vue'), // AI 设置 + }, ], }, ], diff --git a/frontend/src/views/AiSettings.vue b/frontend/src/views/AiSettings.vue new file mode 100644 index 0000000..6debd0f --- /dev/null +++ b/frontend/src/views/AiSettings.vue @@ -0,0 +1,379 @@ + + + + + diff --git a/frontend/src/views/BillImportModal.vue b/frontend/src/views/BillImportModal.vue index 8cd7ff5..2298559 100644 --- a/frontend/src/views/BillImportModal.vue +++ b/frontend/src/views/BillImportModal.vue @@ -104,8 +104,8 @@ function resetFilters() { } // 默认分类 -const expenseCategories = ref<{ label: string; value: number }[]>([]) -const incomeCategories = ref<{ label: string; value: number }[]>([]) +const expenseCategories = ref<{ label: string; value: number; icon: string }[]>([]) +const incomeCategories = ref<{ label: string; value: number; icon: string }[]>([]) const expenseCategoryId = ref(null) const incomeCategoryId = ref(null) @@ -225,13 +225,21 @@ const columns = computed>(() => [ async function loadCategories() { try { const [expenseRes, incomeRes]: any = await Promise.all([getCategoryList('EXPENSE'), getCategoryList('INCOME')]) - expenseCategories.value = (expenseRes.data || []).map((c: any) => ({ label: c.name, value: c.id })) - incomeCategories.value = (incomeRes.data || []).map((c: any) => ({ label: c.name, value: c.id })) + expenseCategories.value = (expenseRes.data || []).map((c: any) => ({ label: c.name, value: c.id, icon: c.icon || '' })) + incomeCategories.value = (incomeRes.data || []).map((c: any) => ({ label: c.name, value: c.id, icon: c.icon || '' })) } catch (e: any) { message.error(e.message || t('import.loadCategoriesFailed')) } } +/** 分类下拉项渲染:图标 + 名称 */ +function renderCategoryLabel(option: any) { + return h('span', null, [ + option.icon ? h('span', { style: { marginRight: '6px' } }, option.icon) : null, + option.label, + ]) +} + /** 加载标签选项 */ async function loadTags() { try { @@ -435,6 +443,7 @@ watch( [ + { label: t('layout.aiSettings'), key: 'ai-settings', icon: renderIcon(SparklesOutline) }, + { label: t('layout.logout'), key: 'logout', icon: renderIcon(LogOutOutline) }, +]) + +/** 用户下拉菜单选择事件:跳转 AI 设置或退出登录 */ +function handleUserMenuSelect(key: string) { + if (key === 'ai-settings') { + router.push('/ai-settings') + } else if (key === 'logout') { + handleLogout() + } +} + /** 退出登录:清空用户状态并跳转到登录页 */ function handleLogout() { userStore.logout() @@ -70,17 +85,16 @@ function handleLogout() { /> - - - {{ userStore.nickname || t('layout.user') }} - + + + - - - {{ t('layout.logout') }} - + + + {{ userStore.nickname || t('layout.user') }} + + + diff --git a/frontend/src/views/TransactionList.vue b/frontend/src/views/TransactionList.vue index 79ab314..ad3a36f 100644 --- a/frontend/src/views/TransactionList.vue +++ b/frontend/src/views/TransactionList.vue @@ -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([]) // AI 建议列表 +const aiQueryText = ref('') // AI 自然语言查询输入 +const aiQuerying = ref(false) // AI 查询解析状态 +const showAiQueryModal = ref(false) // AI 查询结果弹窗显示状态 +const aiQueryResult = ref(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([]) // 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(null) // 批量设置的支出分类 @@ -131,6 +151,12 @@ const filterPaymentMethod = ref([]) // 支付方式筛选(多选) const filterDateRange = ref<[number, number] | null>(null) // 日期范围筛选(起止时间戳) const filterIncludeInStats = ref(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([]) // 备注关键词历史(最近输入,最新在前) const filterMinAmount = ref(null) // 最小金额筛选 const filterMaxAmount = ref(null) // 最大金额筛选 const filterTag = ref([]) // 标签筛选(多选) @@ -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(() => { + 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(() => { + 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>(() => [ + { + 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(() => {