feat: 扩展 AI 记账能力并优化收支记录交互

- AI 能力:新增分类 Emoji 推荐、标签颜色推荐、自然语言生成分类/标签、单条记录分类推荐

- AI 批量建议:按类型+支付方式+备注去重聚合后分批调用,支持分类/标签/两者范围并记忆偏好,结果可勾选应用

- 分类/标签管理:新增 EmojiPicker 与预设色板,支持 AI 一键推荐并在列表可视化

- 收支记录:新增序号列,标签筛选支持无标签,编辑记录时允许不设置分类

- 修复:账单导入未选择默认标签时生成空 IN 列表导致的 SQL 语法错误

- AI 未启用时隐藏所有 AI 入口与弹窗
This commit is contained in:
2026-09-18 09:57:53 +08:00
parent 49c70a732d
commit a8ef91ed3d
30 changed files with 2009 additions and 132 deletions
+100 -6
View File
@@ -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<number | null>(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<CategoryItem> {
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()
})
</script>
<template>
@@ -154,14 +234,28 @@ onMounted(fetchData)
<!-- 新增/编辑弹窗 -->
<NModal v-model:show="showModal" preset="card" :title="editingId ? t('category.editTitle') : t('category.addTitle')" style="width: 450px;">
<NForm>
<NFormItem v-if="aiEnabled" :label="t('ai.category.label')">
<div style="display: flex; gap: 8px; width: 100%;">
<NInput v-model:value="aiText" :placeholder="t('ai.category.placeholder')" @keyup.enter="handleAiGenerate" />
<NButton :loading="aiGenLoading" @click="handleAiGenerate">{{ t('ai.category.button') }}</NButton>
</div>
</NFormItem>
<NFormItem :label="t('category.name')">
<NInput v-model:value="form.name" :placeholder="t('category.namePlaceholder')" />
<div style="display: flex; gap: 8px; width: 100%;">
<NInput v-model:value="form.name" :placeholder="t('category.namePlaceholder')" />
<NButton v-if="aiEnabled" :loading="aiLoading" @click="handleAiEmoji">{{ t('ai.decorate.emojiButton') }}</NButton>
</div>
</NFormItem>
<NFormItem :label="t('category.type')">
<NSelect v-model:value="form.type" :options="typeOptions" />
</NFormItem>
<NFormItem :label="t('category.icon')">
<NInput v-model:value="form.icon" :placeholder="t('category.iconPlaceholder')" />
<EmojiPicker
v-model="form.icon"
:placeholder="t('category.iconPlaceholder')"
:input-placeholder="t('category.emojiInputPlaceholder')"
:clear-text="t('category.clearIcon')"
/>
</NFormItem>
<NFormItem :label="t('category.sort')">
<NInputNumber v-model:value="form.sortOrder" :min="0" />