feat: 扩展 AI 记账能力并优化收支记录交互
- AI 能力:新增分类 Emoji 推荐、标签颜色推荐、自然语言生成分类/标签、单条记录分类推荐 - AI 批量建议:按类型+支付方式+备注去重聚合后分批调用,支持分类/标签/两者范围并记忆偏好,结果可勾选应用 - 分类/标签管理:新增 EmojiPicker 与预设色板,支持 AI 一键推荐并在列表可视化 - 收支记录:新增序号列,标签筛选支持无标签,编辑记录时允许不设置分类 - 修复:账单导入未选择默认标签时生成空 IN 列表导致的 SQL 语法错误 - AI 未启用时隐藏所有 AI 入口与弹窗
This commit is contained in:
@@ -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" />
|
||||
|
||||
Reference in New Issue
Block a user