diff --git a/frontend/src/api/ai.ts b/frontend/src/api/ai.ts index ac86b72..fbb8218 100644 --- a/frontend/src/api/ai.ts +++ b/frontend/src/api/ai.ts @@ -10,9 +10,11 @@ export interface AiConfig { model: string } -/** 单条 AI 建议 */ +/** 单条 AI 建议(按类型、备注、支付方式聚合后的一组记录) */ export interface AiSuggestion { transactionId: number + transactionIds: number[] + count: number categoryId: number | null categoryName: string | null tagIds: number[] @@ -35,9 +37,50 @@ export function testAiConfig() { return request.post('/ai/config/test', {}, { timeout: 60000 }) } -/** 生成分类与标签建议(调用大模型,超时放宽) */ -export function suggestByAi(ids: number[]) { - return request.post('/ai/suggest', { ids }, { timeout: 120000 }) +/** 生成分类与标签建议(scope:BOTH/CATEGORY/TAG;重复记录聚合后按批调用,超时按批数放宽) */ +export function suggestByAi(ids: number[], scope = 'BOTH') { + const batches = Math.max(1, Math.ceil(ids.length / 100)) + return request.post('/ai/suggest', { ids, scope }, { timeout: Math.min(Math.max(120000, batches * 90000), 600000) }) +} + +/** AI 生成的分类建议 */ +export interface AiCategoryResult { + name: string + type: string + icon: string + reason: string +} + +/** 根据自然语言描述生成分类(调用大模型,超时放宽) */ +export function parseCategoryByAi(text: string) { + return request.post('/ai/category', { text }, { timeout: 60000 }) +} + +/** AI 生成的标签建议 */ +export interface AiTagResult { + name: string + color: string + reason: string +} + +/** 根据自然语言描述生成标签(调用大模型,超时放宽) */ +export function parseTagByAi(text: string) { + return request.post('/ai/tag', { text }, { timeout: 60000 }) +} + +/** 根据表单当前内容生成单条分类与标签建议(调用大模型,超时放宽) */ +export function suggestOneByAi(data: { type: string; amount?: number | null; paymentMethod?: string; remark?: string }) { + return request.post('/ai/suggest-one', data, { timeout: 60000 }) +} + +/** AI 推荐分类 Emoji 图标(调用大模型,超时放宽) */ +export function suggestEmoji(name: string, type?: string) { + return request.post('/ai/emoji', { name, type }, { timeout: 60000 }) +} + +/** AI 推荐标签颜色(调用大模型,超时放宽) */ +export function suggestColor(name: string) { + return request.post('/ai/color', { name }, { timeout: 60000 }) } /** 一键应用建议:覆盖分类并追加标签 */ diff --git a/frontend/src/api/transaction.ts b/frontend/src/api/transaction.ts index abfae24..639ac5f 100644 --- a/frontend/src/api/transaction.ts +++ b/frontend/src/api/transaction.ts @@ -38,9 +38,9 @@ export function createTransaction(data: { return request.post('/transaction', data) } -/** 更新指定交易记录 */ +/** 更新指定交易记录(categoryId 为空表示不设置分类) */ export function updateTransaction(id: number, data: { - categoryId: number + categoryId: number | null type: string amount: number paymentMethod?: string diff --git a/frontend/src/components/EmojiPicker.vue b/frontend/src/components/EmojiPicker.vue new file mode 100644 index 0000000..4ef18a5 --- /dev/null +++ b/frontend/src/components/EmojiPicker.vue @@ -0,0 +1,191 @@ + + + + + diff --git a/frontend/src/locales/messages.ts b/frontend/src/locales/messages.ts index bf07dc8..cb2fe64 100644 --- a/frontend/src/locales/messages.ts +++ b/frontend/src/locales/messages.ts @@ -150,6 +150,7 @@ export default { pageIncome: { zh: '本页收入', en: 'Page Income' }, pageBalance: { zh: '本页结余', en: 'Page Balance' }, column: { + index: { zh: '序号', en: 'No.' }, transactionTime: { zh: '交易时间', en: 'Time' }, type: { zh: '类型', en: 'Type' }, category: { zh: '分类', en: 'Category' }, @@ -162,6 +163,7 @@ export default { }, notIncluded: { zh: '不计入', en: 'Excluded' }, uncategorized: { zh: '未分类', en: 'Uncategorized' }, + untagged: { zh: '无标签', en: 'Untagged' }, remarkHidden: { zh: '备注已隐藏', en: 'Remark hidden' }, remarkView: { zh: '查看', en: 'View' }, remarkHide: { zh: '隐藏', en: 'Hide' }, @@ -228,7 +230,9 @@ export default { namePlaceholder: { zh: '请输入分类名称', en: 'Enter category name' }, type: { zh: '类型', en: 'Type' }, icon: { zh: '图标', en: 'Icon' }, - iconPlaceholder: { zh: '可选,如 emoji', en: 'Optional, e.g. emoji' }, + iconPlaceholder: { zh: '点击选择 Emoji', en: 'Click to pick an emoji' }, + emojiInputPlaceholder: { zh: '自定义输入 Emoji 或文字', en: 'Custom emoji or text' }, + clearIcon: { zh: '清除图标', en: 'Clear icon' }, sort: { zh: '排序', en: 'Order' }, nameRequired: { zh: '请输入分类名称', en: 'Please enter a category name' }, deleteConfirm: { zh: '确定要删除这个分类吗?', en: 'Are you sure you want to delete this category?' }, @@ -326,15 +330,57 @@ export default { testSuccess: { zh: '连接成功', en: 'Connection succeeded' }, testFailed: { zh: '连接失败', en: 'Connection failed' }, suggestButton: { zh: 'AI 智能建议', en: 'AI suggestions' }, + pickButton: { zh: 'AI 推荐', en: 'AI Pick' }, + scope: { + label: { zh: '建议内容', en: 'Scope' }, + both: { zh: '分类与标签', en: 'Category & Tags' }, + category: { zh: '仅分类', en: 'Category only' }, + tag: { zh: '仅标签', en: 'Tags only' }, + }, 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})' }, + applyAll: { zh: '应用勾选({count})', en: 'Apply selected ({count})' }, + applyNone: { zh: '请至少勾选一条建议', en: 'Please select at least one suggestion' }, + aggregated: { zh: '已聚合为 {groups} 类、共 {count} 条记录', en: 'Aggregated into {groups} groups, {count} records in total' }, applySuccess: { zh: '已应用 {count} 条建议', en: 'Applied {count} suggestions' }, + oneApplied: { zh: '已应用 AI 推荐分类', en: 'AI category applied' }, + tagApplied: { zh: '已应用 AI 推荐标签', en: 'AI tags applied' }, 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.' }, + decorate: { + emojiButton: { zh: 'AI 推荐', en: 'AI Pick' }, + colorButton: { zh: 'AI 推荐', en: 'AI Pick' }, + nameRequired: { zh: '请先输入名称', en: 'Please enter a name first' }, + failed: { zh: 'AI 推荐失败', en: 'AI recommendation failed' }, + }, + category: { + label: { zh: 'AI 生成分类', en: 'AI Category' }, + placeholder: { zh: '描述用途,如:日常通勤打车地铁', en: 'Describe it, e.g. daily commute by taxi and metro' }, + button: { zh: 'AI 生成', en: 'Generate' }, + textRequired: { zh: '请输入分类描述', en: 'Please enter a description' }, + applied: { zh: '已生成分类,请确认后保存', en: 'Category generated, please confirm and save' }, + failed: { zh: 'AI 生成分类失败', en: 'Failed to generate category' }, + }, + newCategory: { + button: { zh: 'AI 新建', en: 'AI New' }, + hint: { zh: 'AI 建议新分类:', en: 'AI suggests:' }, + save: { zh: '保存并选用', en: 'Save & Select' }, + saved: { zh: '已创建并选用新分类', en: 'Category created and selected' }, + exists: { zh: '已存在同名分类,已为你选中', en: 'A same-name category exists and has been selected' }, + needInfo: { zh: '请先填写备注或金额,便于生成分类', en: 'Please fill in a remark or amount first' }, + }, + newTag: { + button: { zh: 'AI 新建', en: 'AI New' }, + hint: { zh: 'AI 建议新标签:', en: 'AI suggests tag:' }, + save: { zh: '保存并选用', en: 'Save & Select' }, + saved: { zh: '已创建并选用新标签', en: 'Tag created and selected' }, + exists: { zh: '已存在同名标签,已为你选中', en: 'A same-name tag exists and has been selected' }, + needInfo: { zh: '请先填写备注或金额,便于生成标签', en: 'Please fill in a remark or amount first' }, + failed: { zh: 'AI 生成标签失败', en: 'Failed to generate tag' }, + }, 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' }, @@ -369,6 +415,7 @@ export default { }, column: { record: { zh: '记录', en: 'Record' }, + count: { zh: '命中条数', en: 'Matched' }, category: { zh: '建议分类', en: 'Suggested category' }, tags: { zh: '建议标签', en: 'Suggested tags' }, reason: { zh: '理由', en: 'Reason' }, @@ -390,6 +437,8 @@ export default { empty: { zh: '暂无调用记录', en: 'No usage records yet' }, type: { suggest: { zh: '生成建议', en: 'Suggest' }, + decorate: { zh: '图标配色', en: 'Icon & Color' }, + category: { zh: '生成分类', en: 'Category' }, test: { zh: '测试连接', en: 'Test' }, }, column: { diff --git a/frontend/src/views/BillImportModal.vue b/frontend/src/views/BillImportModal.vue index 2298559..acde249 100644 --- a/frontend/src/views/BillImportModal.vue +++ b/frontend/src/views/BillImportModal.vue @@ -331,7 +331,7 @@ async function handleConfirm() { billType: billType.value, expenseCategoryId: expenseCategoryId.value, incomeCategoryId: incomeCategoryId.value, - tagIds: importTagIds.value, + tagIds: importTagIds.value.length ? importTagIds.value : undefined, items, }) result.value = res.data diff --git a/frontend/src/views/CategoryManage.vue b/frontend/src/views/CategoryManage.vue index b76ae8f..708cae3 100644 --- a/frontend/src/views/CategoryManage.vue +++ b/frontend/src/views/CategoryManage.vue @@ -5,6 +5,8 @@ import { useI18n } from 'vue-i18n' import { useMessage, useDialog, NButton, NSpace, NDataTable, NModal, NForm, NFormItem, NInput, NInputNumber, NSelect } from 'naive-ui' import type { DataTableColumns } from 'naive-ui' import { getCategoryList, createCategory, updateCategory, deleteCategory } from '../api/category' +import { getAiConfig, suggestEmoji, parseCategoryByAi } from '../api/ai' +import EmojiPicker from '../components/EmojiPicker.vue' const message = useMessage() // 消息提示 const dialog = useDialog() // 对话框 @@ -26,6 +28,10 @@ const loading = ref(false) // 列表加载状态 const showModal = ref(false) // 新增/编辑弹窗显示状态 const editingId = ref(null) // 当前编辑的分类 ID(null 表示新增) const form = ref({ name: '', type: 'EXPENSE', icon: '', sortOrder: 0 }) // 表单数据 +const aiEnabled = ref(false) // AI 是否启用,用于控制推荐入口显示 +const aiLoading = ref(false) // AI 推荐请求状态 +const aiText = ref('') // AI 生成分类的自然语言描述 +const aiGenLoading = ref(false) // AI 生成分类请求状态 // 类型选项 const typeOptions = computed(() => [ @@ -40,8 +46,24 @@ const expenseList = computed(() => categories.value.filter(c => c.type === 'EXPE /** 创建表格列配置 */ function createColumns(): DataTableColumns { return [ - { title: t('common.name'), key: 'name' }, - { title: t('category.icon'), key: 'icon' }, + { + title: t('common.name'), key: 'name', + render(row) { + // 名称前展示 Emoji 图标,未设置时占位 + return h('span', { style: { display: 'flex', alignItems: 'center', gap: '6px' } }, [ + h('span', { style: { fontSize: '18px', lineHeight: '1' } }, row.icon || '📁'), + h('span', null, row.name), + ]) + }, + }, + { + title: t('category.icon'), key: 'icon', width: 100, + render(row) { + return row.icon + ? h('span', { style: { fontSize: '20px', lineHeight: '1' } }, row.icon) + : h('span', { style: { color: '#ccc' } }, '—') + }, + }, { title: t('common.sort'), key: 'sortOrder', width: 80 }, { title: t('common.operation'), key: 'actions', width: 180, @@ -79,6 +101,7 @@ async function fetchData() { function handleAdd() { editingId.value = null form.value = { name: '', type: 'EXPENSE', icon: '', sortOrder: 0 } + aiText.value = '' showModal.value = true } @@ -86,6 +109,7 @@ function handleAdd() { function handleEdit(row: CategoryItem) { editingId.value = row.id form.value = { name: row.name, type: row.type, icon: row.icon || '', sortOrder: row.sortOrder } + aiText.value = '' showModal.value = true } @@ -131,8 +155,64 @@ function handleDelete(id: number) { }) } -// 组件挂载时加载分类数据 -onMounted(fetchData) +/** 加载 AI 启用状态,用于控制推荐入口显示 */ +async function loadAiConfig() { + try { + const res: any = await getAiConfig() + aiEnabled.value = !!res.data.enabled + } catch (e) { + // 静默处理,推荐入口保持隐藏 + aiEnabled.value = false + } +} + +/** AI 推荐分类 Emoji 图标 */ +async function handleAiEmoji() { + if (!form.value.name) { + message.warning(t('ai.decorate.nameRequired')) + return + } + aiLoading.value = true + try { + const res: any = await suggestEmoji(form.value.name, form.value.type) + form.value.icon = res.data + } catch (e: any) { + message.error(e.message || t('ai.decorate.failed')) + } finally { + aiLoading.value = false + } +} + +/** AI 根据自然语言描述生成分类,并填充名称、类型与图标 */ +async function handleAiGenerate() { + if (!aiText.value.trim()) { + message.warning(t('ai.category.textRequired')) + return + } + aiGenLoading.value = true + try { + const res: any = await parseCategoryByAi(aiText.value.trim()) + const data = res.data + if (!data || !data.name) { + message.warning(t('ai.category.failed')) + return + } + form.value.name = data.name + if (data.type) form.value.type = data.type + if (data.icon) form.value.icon = data.icon + message.success(t('ai.category.applied')) + } catch (e: any) { + message.error(e.message || t('ai.category.failed')) + } finally { + aiGenLoading.value = false + } +} + +// 组件挂载时加载分类数据与 AI 配置 +onMounted(() => { + fetchData() + loadAiConfig() +})