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
+47 -4
View File
@@ -10,9 +10,11 @@ export interface AiConfig {
model: string model: string
} }
/** 单条 AI 建议 */ /** 单条 AI 建议(按类型、备注、支付方式聚合后的一组记录) */
export interface AiSuggestion { export interface AiSuggestion {
transactionId: number transactionId: number
transactionIds: number[]
count: number
categoryId: number | null categoryId: number | null
categoryName: string | null categoryName: string | null
tagIds: number[] tagIds: number[]
@@ -35,9 +37,50 @@ export function testAiConfig() {
return request.post('/ai/config/test', {}, { timeout: 60000 }) return request.post('/ai/config/test', {}, { timeout: 60000 })
} }
/** 生成分类与标签建议(调用大模型,超时放宽) */ /** 生成分类与标签建议(scope:BOTH/CATEGORY/TAG;重复记录聚合后按批调用,超时按批数放宽) */
export function suggestByAi(ids: number[]) { export function suggestByAi(ids: number[], scope = 'BOTH') {
return request.post('/ai/suggest', { ids }, { timeout: 120000 }) 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 })
} }
/** 一键应用建议:覆盖分类并追加标签 */ /** 一键应用建议:覆盖分类并追加标签 */
+2 -2
View File
@@ -38,9 +38,9 @@ export function createTransaction(data: {
return request.post('/transaction', data) return request.post('/transaction', data)
} }
/** 更新指定交易记录 */ /** 更新指定交易记录(categoryId 为空表示不设置分类) */
export function updateTransaction(id: number, data: { export function updateTransaction(id: number, data: {
categoryId: number categoryId: number | null
type: string type: string
amount: number amount: number
paymentMethod?: string paymentMethod?: string
+191
View File
@@ -0,0 +1,191 @@
<script setup lang="ts">
// Emoji 选择器组件:提供常用 Emoji 分组快速点选与自定义文本输入,供分类图标等场景复用
import { computed, ref, watch } from 'vue'
import { NPopover, NInput, NButton, NScrollbar } from 'naive-ui'
/** 组件属性 */
const props = withDefaults(defineProps<{
/** 当前选中的 Emoji 或图标文本 */
modelValue?: string
/** 未选择时的占位提示 */
placeholder?: string
/** 自定义输入框的占位提示 */
inputPlaceholder?: string
/** 清除按钮文案 */
clearText?: string
}>(), {
modelValue: '',
placeholder: '',
inputPlaceholder: '',
clearText: '',
})
const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
/** Emoji 分组数据,按常见记账场景归类 */
const EMOJI_GROUPS: { key: string; label: string; emojis: string[] }[] = [
{ key: 'dining', label: '餐饮', emojis: ['🍚', '🍜', '🍔', '🍕', '🍰', '☕', '🍺', '🍎', '🥤', '🍦', '🍟', '🍣', '🥗', '🍲'] },
{ key: 'transport', label: '交通', emojis: ['🚗', '🚕', '🚌', '🚇', '🚄', '✈️', '🚲', '⛽', '🅿️', '🛵', '🚢', '🚝', '🚦', '🛺'] },
{ key: 'shopping', label: '购物', emojis: ['🛒', '👕', '👟', '👜', '💄', '📱', '💻', '🎁', '🧴', '👓', '⌚', '🎧', '💍', '🧢'] },
{ key: 'home', label: '居家', emojis: ['🏠', '💡', '💧', '🔥', '🛋️', '🧹', '🧺', '🪑', '🚿', '🛏️', '📺', '🧻', '🪴', '🔌'] },
{ key: 'fun', label: '娱乐', emojis: ['🎮', '🎬', '🎵', '⚽', '🏀', '🎤', '🎨', '📷', '🎳', '🏋️', '🎯', '🎪', '🎫', '🎲'] },
{ key: 'health', label: '医疗', emojis: ['💊', '🏥', '🩺', '💉', '🦷', '👶', '🧘', '🩹', '🩻', '🧑‍⚕️', '🏃', '😴'] },
{ key: 'study', label: '学习', emojis: ['📚', '✏️', '🎓', '🖊️', '📝', '🧑‍🏫', '📖', '🔬', '🗂️', '📐', '🖥️', '📅'] },
{ key: 'finance', label: '收支', emojis: ['💰', '💵', '💳', '🏦', '📈', '📉', '🧾', '🪙', '💴', '💶', '💷', '🤑', '🧧', '🏧'] },
{ key: 'other', label: '其他', emojis: ['📦', '🧳', '🐱', '🐶', '🌱', '🎉', '❤️', '⭐', '🔧', '🧸', '🍀', '🌈', '🕒', '📌'] },
]
const show = ref(false) // 选择面板显示状态
const keyword = ref(props.modelValue || '') // 自定义输入内容,与选中值同步
// 外部值变化时同步到输入框,保证编辑回填正确
watch(() => props.modelValue, (val) => {
if ((val || '') !== keyword.value) keyword.value = val || ''
})
/** 触发框展示文本:优先显示已选图标,否则显示占位提示 */
const displayText = computed(() => props.modelValue || props.placeholder)
/** 自定义输入变化时实时回写选中值 */
function handleInput(value: string) {
keyword.value = value
emit('update:modelValue', value)
}
/** 点选预设 Emoji:回写并关闭面板 */
function handleSelect(emoji: string) {
keyword.value = emoji
emit('update:modelValue', emoji)
show.value = false
}
/** 清除已选图标 */
function handleClear() {
keyword.value = ''
emit('update:modelValue', '')
show.value = false
}
</script>
<template>
<NPopover v-model:show="show" trigger="click" placement="bottom-start" :width="360">
<template #trigger>
<div class="emoji-trigger" :class="{ 'is-empty': !props.modelValue }">
<span class="emoji-current">{{ displayText }}</span>
<span class="emoji-arrow">▾</span>
</div>
</template>
<div class="emoji-panel">
<!-- 自定义输入:可直接输入任意 Emoji 或文字,兼容旧的纯文本图标 -->
<NInput
:value="keyword"
size="small"
clearable
:placeholder="props.inputPlaceholder"
@update:value="handleInput"
/>
<NScrollbar style="max-height: 260px; margin-top: 8px;">
<div v-for="group in EMOJI_GROUPS" :key="group.key">
<div class="emoji-group-label">{{ group.label }}</div>
<div class="emoji-grid">
<button
v-for="emoji in group.emojis"
:key="emoji"
type="button"
class="emoji-item"
:class="{ active: emoji === props.modelValue }"
@click="handleSelect(emoji)"
>
{{ emoji }}
</button>
</div>
</div>
</NScrollbar>
<div class="emoji-footer">
<NButton size="tiny" quaternary @click="handleClear">{{ props.clearText }}</NButton>
</div>
</div>
</NPopover>
</template>
<style scoped>
.emoji-trigger {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
height: 34px;
padding: 0 12px;
box-sizing: border-box;
background: #fff;
border: 1px solid #e0e0e6;
border-radius: 3px;
cursor: pointer;
transition: border-color 0.2s;
}
.emoji-trigger:hover {
border-color: #36ad6a;
}
.emoji-current {
font-size: 20px;
line-height: 1;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.emoji-trigger.is-empty .emoji-current {
font-size: 14px;
color: #c2c2c2;
}
.emoji-arrow {
margin-left: 8px;
color: #999;
font-size: 12px;
}
.emoji-grid {
display: grid;
grid-template-columns: repeat(8, 1fr);
gap: 4px;
}
.emoji-item {
padding: 4px 0;
font-size: 20px;
line-height: 1;
background: transparent;
border: none;
border-radius: 6px;
cursor: pointer;
transition: background 0.15s;
}
.emoji-item:hover {
background: #f0f0f0;
}
.emoji-item.active {
background: #e6f7ec;
}
.emoji-group-label {
margin: 8px 0 4px;
font-size: 12px;
color: #999;
}
.emoji-footer {
display: flex;
justify-content: flex-end;
margin-top: 8px;
padding-top: 8px;
border-top: 1px solid #f0f0f0;
}
</style>
+51 -2
View File
@@ -150,6 +150,7 @@ export default {
pageIncome: { zh: '本页收入', en: 'Page Income' }, pageIncome: { zh: '本页收入', en: 'Page Income' },
pageBalance: { zh: '本页结余', en: 'Page Balance' }, pageBalance: { zh: '本页结余', en: 'Page Balance' },
column: { column: {
index: { zh: '序号', en: 'No.' },
transactionTime: { zh: '交易时间', en: 'Time' }, transactionTime: { zh: '交易时间', en: 'Time' },
type: { zh: '类型', en: 'Type' }, type: { zh: '类型', en: 'Type' },
category: { zh: '分类', en: 'Category' }, category: { zh: '分类', en: 'Category' },
@@ -162,6 +163,7 @@ export default {
}, },
notIncluded: { zh: '不计入', en: 'Excluded' }, notIncluded: { zh: '不计入', en: 'Excluded' },
uncategorized: { zh: '未分类', en: 'Uncategorized' }, uncategorized: { zh: '未分类', en: 'Uncategorized' },
untagged: { zh: '无标签', en: 'Untagged' },
remarkHidden: { zh: '备注已隐藏', en: 'Remark hidden' }, remarkHidden: { zh: '备注已隐藏', en: 'Remark hidden' },
remarkView: { zh: '查看', en: 'View' }, remarkView: { zh: '查看', en: 'View' },
remarkHide: { zh: '隐藏', en: 'Hide' }, remarkHide: { zh: '隐藏', en: 'Hide' },
@@ -228,7 +230,9 @@ export default {
namePlaceholder: { zh: '请输入分类名称', en: 'Enter category name' }, namePlaceholder: { zh: '请输入分类名称', en: 'Enter category name' },
type: { zh: '类型', en: 'Type' }, type: { zh: '类型', en: 'Type' },
icon: { zh: '图标', en: 'Icon' }, 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' }, sort: { zh: '排序', en: 'Order' },
nameRequired: { zh: '请输入分类名称', en: 'Please enter a category name' }, nameRequired: { zh: '请输入分类名称', en: 'Please enter a category name' },
deleteConfirm: { zh: '确定要删除这个分类吗?', en: 'Are you sure you want to delete this category?' }, deleteConfirm: { zh: '确定要删除这个分类吗?', en: 'Are you sure you want to delete this category?' },
@@ -326,15 +330,57 @@ export default {
testSuccess: { zh: '连接成功', en: 'Connection succeeded' }, testSuccess: { zh: '连接成功', en: 'Connection succeeded' },
testFailed: { zh: '连接失败', en: 'Connection failed' }, testFailed: { zh: '连接失败', en: 'Connection failed' },
suggestButton: { zh: 'AI 智能建议', en: 'AI suggestions' }, 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' }, suggestTitle: { zh: 'AI 分类与标签建议', en: 'AI category & tag suggestions' },
suggesting: { zh: '正在分析,请稍候…', en: 'Analyzing, please wait…' }, suggesting: { zh: '正在分析,请稍候…', en: 'Analyzing, please wait…' },
suggestFailed: { zh: 'AI 建议生成失败', en: 'Failed to generate AI suggestions' }, suggestFailed: { zh: 'AI 建议生成失败', en: 'Failed to generate AI suggestions' },
noSuggestion: { zh: 'AI 未返回有效建议', en: 'AI returned no valid 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' }, 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' }, applyFailed: { zh: '应用建议失败', en: 'Failed to apply suggestions' },
applyHint: { zh: '将覆盖分类并追加标签,确认后立即写入', en: 'Categories will be overwritten and tags appended; changes are written immediately.' }, 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.' }, 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: { query: {
placeholder: { zh: '用自然语言查询,如:上个月餐饮花了多少', en: 'Ask in natural language, e.g. dining expenses last month' }, 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' }, examples: { zh: '上个月餐饮|超过500的支出|待报销的支出|微信支付', en: 'Dining last month|Expenses over 500|Pending reimbursement|Paid by WeChat' },
@@ -369,6 +415,7 @@ export default {
}, },
column: { column: {
record: { zh: '记录', en: 'Record' }, record: { zh: '记录', en: 'Record' },
count: { zh: '命中条数', en: 'Matched' },
category: { zh: '建议分类', en: 'Suggested category' }, category: { zh: '建议分类', en: 'Suggested category' },
tags: { zh: '建议标签', en: 'Suggested tags' }, tags: { zh: '建议标签', en: 'Suggested tags' },
reason: { zh: '理由', en: 'Reason' }, reason: { zh: '理由', en: 'Reason' },
@@ -390,6 +437,8 @@ export default {
empty: { zh: '暂无调用记录', en: 'No usage records yet' }, empty: { zh: '暂无调用记录', en: 'No usage records yet' },
type: { type: {
suggest: { zh: '生成建议', en: 'Suggest' }, suggest: { zh: '生成建议', en: 'Suggest' },
decorate: { zh: '图标配色', en: 'Icon & Color' },
category: { zh: '生成分类', en: 'Category' },
test: { zh: '测试连接', en: 'Test' }, test: { zh: '测试连接', en: 'Test' },
}, },
column: { column: {
+1 -1
View File
@@ -331,7 +331,7 @@ async function handleConfirm() {
billType: billType.value, billType: billType.value,
expenseCategoryId: expenseCategoryId.value, expenseCategoryId: expenseCategoryId.value,
incomeCategoryId: incomeCategoryId.value, incomeCategoryId: incomeCategoryId.value,
tagIds: importTagIds.value, tagIds: importTagIds.value.length ? importTagIds.value : undefined,
items, items,
}) })
result.value = res.data result.value = res.data
+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 { useMessage, useDialog, NButton, NSpace, NDataTable, NModal, NForm, NFormItem, NInput, NInputNumber, NSelect } from 'naive-ui'
import type { DataTableColumns } from 'naive-ui' import type { DataTableColumns } from 'naive-ui'
import { getCategoryList, createCategory, updateCategory, deleteCategory } from '../api/category' 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 message = useMessage() // 消息提示
const dialog = useDialog() // 对话框 const dialog = useDialog() // 对话框
@@ -26,6 +28,10 @@ const loading = ref(false) // 列表加载状态
const showModal = ref(false) // 新增/编辑弹窗显示状态 const showModal = ref(false) // 新增/编辑弹窗显示状态
const editingId = ref<number | null>(null) // 当前编辑的分类 ID(null 表示新增) const editingId = ref<number | null>(null) // 当前编辑的分类 ID(null 表示新增)
const form = ref({ name: '', type: 'EXPENSE', icon: '', sortOrder: 0 }) // 表单数据 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(() => [ const typeOptions = computed(() => [
@@ -40,8 +46,24 @@ const expenseList = computed(() => categories.value.filter(c => c.type === 'EXPE
/** 创建表格列配置 */ /** 创建表格列配置 */
function createColumns(): DataTableColumns<CategoryItem> { function createColumns(): DataTableColumns<CategoryItem> {
return [ 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.sort'), key: 'sortOrder', width: 80 },
{ {
title: t('common.operation'), key: 'actions', width: 180, title: t('common.operation'), key: 'actions', width: 180,
@@ -79,6 +101,7 @@ async function fetchData() {
function handleAdd() { function handleAdd() {
editingId.value = null editingId.value = null
form.value = { name: '', type: 'EXPENSE', icon: '', sortOrder: 0 } form.value = { name: '', type: 'EXPENSE', icon: '', sortOrder: 0 }
aiText.value = ''
showModal.value = true showModal.value = true
} }
@@ -86,6 +109,7 @@ function handleAdd() {
function handleEdit(row: CategoryItem) { function handleEdit(row: CategoryItem) {
editingId.value = row.id editingId.value = row.id
form.value = { name: row.name, type: row.type, icon: row.icon || '', sortOrder: row.sortOrder } form.value = { name: row.name, type: row.type, icon: row.icon || '', sortOrder: row.sortOrder }
aiText.value = ''
showModal.value = true showModal.value = true
} }
@@ -131,8 +155,64 @@ function handleDelete(id: number) {
}) })
} }
// 组件挂载时加载分类数据 /** 加载 AI 启用状态,用于控制推荐入口显示 */
onMounted(fetchData) 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> </script>
<template> <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;"> <NModal v-model:show="showModal" preset="card" :title="editingId ? t('category.editTitle') : t('category.addTitle')" style="width: 450px;">
<NForm> <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')"> <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>
<NFormItem :label="t('category.type')"> <NFormItem :label="t('category.type')">
<NSelect v-model:value="form.type" :options="typeOptions" /> <NSelect v-model:value="form.type" :options="typeOptions" />
</NFormItem> </NFormItem>
<NFormItem :label="t('category.icon')"> <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>
<NFormItem :label="t('category.sort')"> <NFormItem :label="t('category.sort')">
<NInputNumber v-model:value="form.sortOrder" :min="0" /> <NInputNumber v-model:value="form.sortOrder" :min="0" />
+63 -5
View File
@@ -5,6 +5,7 @@ import { useI18n } from 'vue-i18n'
import { useMessage, useDialog, NButton, NSpace, NDataTable, NModal, NForm, NFormItem, NInput, NInputNumber, NColorPicker, NTag } from 'naive-ui' import { useMessage, useDialog, NButton, NSpace, NDataTable, NModal, NForm, NFormItem, NInput, NInputNumber, NColorPicker, NTag } from 'naive-ui'
import type { DataTableColumns } from 'naive-ui' import type { DataTableColumns } from 'naive-ui'
import { getTagList, createTag, updateTag, deleteTag } from '../api/tag' import { getTagList, createTag, updateTag, deleteTag } from '../api/tag'
import { getAiConfig, suggestColor } from '../api/ai'
const message = useMessage() // 消息提示 const message = useMessage() // 消息提示
const dialog = useDialog() // 对话框 const dialog = useDialog() // 对话框
@@ -25,6 +26,14 @@ const loading = ref(false) // 列表加载状态
const showModal = ref(false) // 新增/编辑弹窗显示状态 const showModal = ref(false) // 新增/编辑弹窗显示状态
const editingId = ref<number | null>(null) // 当前编辑的标签 ID(null 表示新增) const editingId = ref<number | null>(null) // 当前编辑的标签 ID(null 表示新增)
const form = ref<{ name: string; color: string; sortOrder: number }>({ name: '', color: '#18a058', sortOrder: 0 }) // 表单数据 const form = ref<{ name: string; color: string; sortOrder: number }>({ name: '', color: '#18a058', sortOrder: 0 }) // 表单数据
const aiEnabled = ref(false) // AI 是否启用,用于控制推荐入口显示
const aiLoading = ref(false) // AI 推荐请求状态
// 预设色板:覆盖常用区分色,便于快速选择
const colorSwatches = [
'#18a058', '#2080f0', '#f0a020', '#d03050', '#7c3aed', '#13c2c2',
'#eb2f96', '#fa8c16', '#2f54eb', '#52c41a', '#8c8c8c', '#000000',
]
/** 创建表格列配置 */ /** 创建表格列配置 */
function createColumns(): DataTableColumns<TagItem> { function createColumns(): DataTableColumns<TagItem> {
@@ -35,7 +44,22 @@ function createColumns(): DataTableColumns<TagItem> {
return h(NTag, { color: { color: row.color || '#2080f0', textColor: '#fff' } }, { default: () => row.name }) return h(NTag, { color: { color: row.color || '#2080f0', textColor: '#fff' } }, { default: () => row.name })
}, },
}, },
{ title: t('tag.color'), key: 'color', width: 140 }, {
title: t('tag.color'), key: 'color', width: 160,
render(row) {
// 色块 + 色值,直观展示标签颜色
const color = row.color || '#2080f0'
return h('div', { style: { display: 'flex', alignItems: 'center', gap: '6px' } }, [
h('span', {
style: {
display: 'inline-block', width: '16px', height: '16px', borderRadius: '4px',
background: color, border: '1px solid rgba(0, 0, 0, 0.1)',
},
}),
h('span', { style: { color: '#666' } }, color),
])
},
},
{ title: t('common.sort'), key: 'sortOrder', width: 80 }, { title: t('common.sort'), key: 'sortOrder', width: 80 },
{ {
title: t('common.operation'), key: 'actions', width: 180, title: t('common.operation'), key: 'actions', width: 180,
@@ -120,8 +144,39 @@ function handleDelete(id: number) {
}) })
} }
// 组件挂载时加载标签数据 /** 加载 AI 启用状态,用于控制推荐入口显示 */
onMounted(fetchData) async function loadAiConfig() {
try {
const res: any = await getAiConfig()
aiEnabled.value = !!res.data.enabled
} catch (e) {
// 静默处理,推荐入口保持隐藏
aiEnabled.value = false
}
}
/** AI 推荐标签颜色 */
async function handleAiColor() {
if (!form.value.name) {
message.warning(t('ai.decorate.nameRequired'))
return
}
aiLoading.value = true
try {
const res: any = await suggestColor(form.value.name)
form.value.color = res.data
} catch (e: any) {
message.error(e.message || t('ai.decorate.failed'))
} finally {
aiLoading.value = false
}
}
// 组件挂载时加载标签数据与 AI 配置
onMounted(() => {
fetchData()
loadAiConfig()
})
</script> </script>
<template> <template>
@@ -139,10 +194,13 @@ onMounted(fetchData)
<NModal v-model:show="showModal" preset="card" :title="editingId ? t('tag.editTitle') : t('tag.addTitle')" style="width: 450px;"> <NModal v-model:show="showModal" preset="card" :title="editingId ? t('tag.editTitle') : t('tag.addTitle')" style="width: 450px;">
<NForm> <NForm>
<NFormItem :label="t('tag.name')"> <NFormItem :label="t('tag.name')">
<NInput v-model:value="form.name" :placeholder="t('tag.namePlaceholder')" /> <div style="display: flex; gap: 8px; width: 100%;">
<NInput v-model:value="form.name" :placeholder="t('tag.namePlaceholder')" />
<NButton v-if="aiEnabled" :loading="aiLoading" @click="handleAiColor">{{ t('ai.decorate.colorButton') }}</NButton>
</div>
</NFormItem> </NFormItem>
<NFormItem :label="t('tag.color')"> <NFormItem :label="t('tag.color')">
<NColorPicker v-model:value="form.color" /> <NColorPicker v-model:value="form.color" :swatches="colorSwatches" :show-alpha="false" />
</NFormItem> </NFormItem>
<NFormItem :label="t('tag.sort')"> <NFormItem :label="t('tag.sort')">
<NInputNumber v-model:value="form.sortOrder" :min="0" /> <NInputNumber v-model:value="form.sortOrder" :min="0" />
+352 -54
View File
@@ -6,10 +6,10 @@ import { useMessage, useDialog, NButton, NSpace, NDataTable, NTag, NModal, NForm
import { EyeOutline, EyeOffOutline, CloseCircleOutline, SparklesOutline, BulbOutline } from '@vicons/ionicons5' import { EyeOutline, EyeOffOutline, CloseCircleOutline, SparklesOutline, BulbOutline } from '@vicons/ionicons5'
import type { DataTableColumns, AutoCompleteOption } from 'naive-ui' import type { DataTableColumns, AutoCompleteOption } from 'naive-ui'
import { getTransactionPage, createTransaction, updateTransaction, deleteTransaction, exportTransaction, toggleTransactionStats, toggleRemarkHidden, batchUpdateCategory, batchTags } from '../api/transaction' import { getTransactionPage, createTransaction, updateTransaction, deleteTransaction, exportTransaction, toggleTransactionStats, toggleRemarkHidden, batchUpdateCategory, batchTags } from '../api/transaction'
import { getAiConfig, suggestByAi, applyAiSuggestions, queryByAi, parseRecordByAi } from '../api/ai' import { getAiConfig, suggestByAi, suggestOneByAi, applyAiSuggestions, queryByAi, parseRecordByAi, parseCategoryByAi, parseTagByAi } from '../api/ai'
import type { AiSuggestion, AiQueryResult, AiRecordResult } from '../api/ai' import type { AiSuggestion, AiQueryResult, AiRecordResult } from '../api/ai'
import { getCategoryList } from '../api/category' import { getCategoryList, createCategory } from '../api/category'
import { getTagList } from '../api/tag' import { getTagList, createTag } from '../api/tag'
import BillImportModal from './BillImportModal.vue' import BillImportModal from './BillImportModal.vue'
const message = useMessage() // 消息提示 const message = useMessage() // 消息提示
@@ -83,8 +83,47 @@ const showBatchModal = ref(false) // 批量设置分类弹窗显示状态
const showAiModal = ref(false) // AI 建议弹窗显示状态 const showAiModal = ref(false) // AI 建议弹窗显示状态
const aiEnabled = ref(false) // AI 建议是否启用 const aiEnabled = ref(false) // AI 建议是否启用
const aiLoading = ref(false) // AI 建议生成状态 const aiLoading = ref(false) // AI 建议生成状态
const aiFormLoading = ref(false) // 编辑弹窗内 AI 推荐状态
/** AI 建议范围偏好存储键 */
const AI_SCOPE_KEY = 'finance.transaction.aiScope'
/** 读取上次的 AI 建议范围偏好,非法值回退 BOTH */
function loadAiScope(): string {
try {
const saved = localStorage.getItem(AI_SCOPE_KEY)
return saved && ['BOTH', 'CATEGORY', 'TAG'].includes(saved) ? saved : 'BOTH'
} catch (e) {
return 'BOTH'
}
}
const aiScope = ref(loadAiScope()) // 批量 AI 建议范围:BOTH/CATEGORY/TAG(记忆用户偏好)
// 批量 AI 建议范围选项
const aiScopeOptions = computed(() => [
{ label: t('ai.scope.both'), value: 'BOTH' },
{ label: t('ai.scope.category'), value: 'CATEGORY' },
{ label: t('ai.scope.tag'), value: 'TAG' },
])
// 记住用户选择的 AI 建议范围
watch(aiScope, (value) => {
try {
localStorage.setItem(AI_SCOPE_KEY, value)
} catch (e) {
// 忽略存储异常
}
})
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 aiTagSaving = ref(false) // 新标签保存状态
// AI 生成但尚未保存的新标签,需用户确认后再落库
const pendingTag = ref<{ name: string; color: string } | null>(null)
const aiApplying = ref(false) // AI 建议应用状态 const aiApplying = ref(false) // AI 建议应用状态
const aiSuggestions = ref<AiSuggestion[]>([]) // AI 建议列表 const aiSuggestions = ref<AiSuggestion[]>([]) // AI 建议列表
const checkedAiRowKeys = ref<number[]>([]) // AI 建议弹窗中已勾选的代表记录ID
const aiQueryText = ref('') // AI 自然语言查询输入 const aiQueryText = ref('') // AI 自然语言查询输入
const aiQuerying = ref(false) // AI 查询解析状态 const aiQuerying = ref(false) // AI 查询解析状态
const showAiQueryModal = ref(false) // AI 查询结果弹窗显示状态 const showAiQueryModal = ref(false) // AI 查询结果弹窗显示状态
@@ -106,6 +145,9 @@ const batchIncludeInStats = ref<'null' | 'true' | 'false'>('null') // 批量设
const batchAddTagIds = ref<number[]>([]) // 批量添加的标签 const batchAddTagIds = ref<number[]>([]) // 批量添加的标签
const batchRemoveTagIds = ref<number[]>([]) // 批量移除的标签 const batchRemoveTagIds = ref<number[]>([]) // 批量移除的标签
const batching = ref(false) // 批量设置提交状态 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 batchStatsOptions = computed(() => [ const batchStatsOptions = computed(() => [
@@ -190,6 +232,21 @@ const categoryFilterOptions = computed(() => [
// 标签筛选项 // 标签筛选项
const tagFilterOptions = computed(() => allTags.value.map((t) => ({ label: t.name, value: t.id }))) const tagFilterOptions = computed(() => allTags.value.map((t) => ({ label: t.name, value: t.id })))
// 标签筛选下拉项:额外提供「无标签」选项(仅筛选使用,编辑/批量选择不包含)
const tagFilterSelectOptions = computed(() => [
{ label: t('transaction.untagged'), value: 0 },
...tagFilterOptions.value,
])
/** 将标签筛选拆分为标签ID与无标签标记,供查询与导出复用 */
function buildTagFilter() {
const ids = filterTag.value.filter((v) => v !== 0)
return {
tagIds: ids.length > 0 ? ids : undefined,
untagged: filterTag.value.includes(0) ? true : undefined,
}
}
/** 将分类筛选拆分为分类ID与未分类标记,供查询与导出复用 */ /** 将分类筛选拆分为分类ID与未分类标记,供查询与导出复用 */
function buildCategoryFilter() { function buildCategoryFilter() {
const ids = filterCategory.value.filter((v) => v !== 0) const ids = filterCategory.value.filter((v) => v !== 0)
@@ -427,6 +484,13 @@ async function setRemarkHidden(row: TransactionItem, hidden: boolean) {
/** 创建表格列配置 */ /** 创建表格列配置 */
function createColumns(): DataTableColumns<TransactionItem> { function createColumns(): DataTableColumns<TransactionItem> {
return [ return [
{
// 序号列:按分页计算全局序号
title: t('transaction.column.index'), key: 'index', width: 70, align: 'center',
render(_row, index) {
return (page.value - 1) * pageSize.value + index + 1
},
},
{ {
title: t('transaction.column.transactionTime'), key: 'transactionTime', width: 170, title: t('transaction.column.transactionTime'), key: 'transactionTime', width: 170,
render(row) { render(row) {
@@ -562,7 +626,7 @@ async function fetchData() {
remark: filterRemark.value.trim() || undefined, remark: filterRemark.value.trim() || undefined,
sortField: sortField.value, sortField: sortField.value,
sortOrder: sortOrder.value, sortOrder: sortOrder.value,
tagIds: filterTag.value.length > 0 ? filterTag.value : undefined, ...buildTagFilter(),
}) })
transactions.value = res.data.records transactions.value = res.data.records
total.value = res.data.total total.value = res.data.total
@@ -598,6 +662,8 @@ function handleViewRaw(row: TransactionItem) {
function handleAdd() { function handleAdd() {
editingId.value = null editingId.value = null
form.value = { categoryId: null, type: 'EXPENSE', amount: null, paymentMethod: '', remark: '', transactionTime: Date.now(), includeInStats: true, tagIds: [] } form.value = { categoryId: null, type: 'EXPENSE', amount: null, paymentMethod: '', remark: '', transactionTime: Date.now(), includeInStats: true, tagIds: [] }
pendingCategory.value = null
pendingTag.value = null
showModal.value = true showModal.value = true
} }
@@ -614,12 +680,15 @@ function handleEdit(row: TransactionItem) {
includeInStats: row.includeInStats ?? true, includeInStats: row.includeInStats ?? true,
tagIds: (row.tags || []).map((t) => t.id), tagIds: (row.tags || []).map((t) => t.id),
} }
pendingCategory.value = null
pendingTag.value = null
showModal.value = true showModal.value = true
} }
/** 提交表单:校验 -> 新增或更新 -> 刷新列表 */ /** 提交表单:校验 -> 新增或更新 -> 刷新列表 */
async function handleSubmit() { async function handleSubmit() {
if (!form.value.categoryId) { // 编辑已有记录时允许不设置分类,便于只修改标签等信息
if (!form.value.categoryId && !editingId.value) {
message.warning(t('transaction.selectCategory')) message.warning(t('transaction.selectCategory'))
return return
} }
@@ -736,12 +805,15 @@ async function handleAiSuggest() {
aiLoading.value = true aiLoading.value = true
try { try {
const ids = transactions.value.map((t) => t.id) const ids = transactions.value.map((t) => t.id)
const res: any = await suggestByAi(ids) const res: any = await suggestByAi(ids, aiScope.value)
aiSuggestions.value = res.data || [] aiSuggestions.value = res.data || []
if (aiSuggestions.value.length === 0) { if (aiSuggestions.value.length === 0) {
message.warning(t('ai.noSuggestion')) message.warning(t('ai.noSuggestion'))
return return
} }
// 默认全选,用户可取消不需要的建议
checkedAiRowKeys.value = aiSuggestions.value.map((s) => s.transactionId)
message.success(t('ai.aggregated', { groups: aiSuggestions.value.length, count: aiTotalCount.value }))
showBatchModal.value = false showBatchModal.value = false
showAiModal.value = true showAiModal.value = true
} catch (e: any) { } catch (e: any) {
@@ -751,18 +823,197 @@ async function handleAiSuggest() {
} }
} }
/** 编辑弹窗内 AI 推荐分类:基于表单当前内容(类型、金额、支付方式、备注) */
async function handleAiSuggestOne() {
aiFormLoading.value = true
try {
const res: any = await suggestOneByAi({
type: form.value.type,
amount: form.value.amount,
paymentMethod: form.value.paymentMethod || undefined,
remark: form.value.remark || undefined,
})
const suggestion = res.data
if (!suggestion || suggestion.categoryId == null) {
message.warning(t('ai.noSuggestion'))
return
}
form.value.categoryId = suggestion.categoryId
message.success(t('ai.oneApplied'))
} catch (e: any) {
message.error(e.message || t('ai.suggestFailed'))
} finally {
aiFormLoading.value = false
}
}
/** 编辑弹窗内根据账单信息生成新分类(暂不保存,等待用户确认) */
async function handleAiNewCategory() {
if (!form.value.remark && !form.value.amount) {
message.warning(t('ai.newCategory.needInfo'))
return
}
aiNewLoading.value = true
try {
const res: any = await parseCategoryByAi(buildCategoryPrompt())
const data = res.data
if (!data || !data.name) {
message.warning(t('ai.category.failed'))
return
}
// 类型跟随当前记录,避免与所选收支类型不一致
pendingCategory.value = { name: data.name, type: form.value.type, icon: data.icon || '' }
} catch (e: any) {
message.error(e.message || t('ai.category.failed'))
} finally {
aiNewLoading.value = false
}
}
/** 拼接用于生成分类的账单描述 */
function buildCategoryPrompt() {
const typeLabel = form.value.type === 'INCOME' ? t('common.types.income') : t('common.types.expense')
const parts = [`这是一笔${typeLabel}记录`]
if (form.value.remark) parts.push(`备注:${form.value.remark}`)
if (form.value.amount) parts.push(`金额:${form.value.amount}`)
if (form.value.paymentMethod) parts.push(`支付方式:${getPaymentLabel(form.value.paymentMethod)}`)
parts.push('请生成一个能涵盖这类记录的记账分类')
return parts.join(',')
}
/** 确认保存 AI 生成的新分类并选用 */
async function handleSaveNewCategory() {
const pending = pendingCategory.value
if (!pending) {
return
}
// 已存在同名同类型分类时直接选用,避免重复创建
const existing = categories.value.find((c) => c.label === pending.name && c.type === pending.type)
if (existing) {
form.value.categoryId = existing.value
message.info(t('ai.newCategory.exists'))
pendingCategory.value = null
return
}
aiNewSaving.value = true
try {
await createCategory({ name: pending.name, type: pending.type, icon: pending.icon, sortOrder: 0 })
await fetchCategories()
// 排序值相同,取同名同类型中的最后一个(最新创建)
const created = [...categories.value].reverse().find((c) => c.label === pending.name && c.type === pending.type)
if (created) {
form.value.categoryId = created.value
}
message.success(t('ai.newCategory.saved'))
pendingCategory.value = null
} catch (e: any) {
message.error(e.message || t('common.operationFailed'))
} finally {
aiNewSaving.value = false
}
}
/** 编辑弹窗内 AI 推荐已有标签:基于表单当前内容,追加到已选标签 */
async function handleAiTagSuggest() {
aiTagLoading.value = true
try {
const res: any = await suggestOneByAi({
type: form.value.type,
amount: form.value.amount,
paymentMethod: form.value.paymentMethod || undefined,
remark: form.value.remark || undefined,
})
const suggestion = res.data
if (!suggestion || !suggestion.tagIds || suggestion.tagIds.length === 0) {
message.warning(t('ai.noSuggestion'))
return
}
// 合并去重,保留已选标签
form.value.tagIds = Array.from(new Set([...form.value.tagIds, ...suggestion.tagIds]))
message.success(t('ai.tagApplied'))
} catch (e: any) {
message.error(e.message || t('ai.suggestFailed'))
} finally {
aiTagLoading.value = false
}
}
/** 编辑弹窗内根据账单信息生成新标签(暂不保存,等待用户确认) */
async function handleAiNewTag() {
if (!form.value.remark && !form.value.amount) {
message.warning(t('ai.newTag.needInfo'))
return
}
aiTagLoading.value = true
try {
const res: any = await parseTagByAi(buildCategoryPrompt())
const data = res.data
if (!data || !data.name) {
message.warning(t('ai.newTag.failed'))
return
}
pendingTag.value = { name: data.name, color: data.color || '#2080f0' }
} catch (e: any) {
message.error(e.message || t('ai.newTag.failed'))
} finally {
aiTagLoading.value = false
}
}
/** 确认保存 AI 生成的新标签并选用 */
async function handleSaveNewTag() {
const pending = pendingTag.value
if (!pending) {
return
}
// 已存在同名标签时直接选用,避免重复创建
const existing = allTags.value.find((tag) => tag.name === pending.name)
if (existing) {
addTagToForm(existing.id)
message.info(t('ai.newTag.exists'))
pendingTag.value = null
return
}
aiTagSaving.value = true
try {
await createTag({ name: pending.name, color: pending.color, sortOrder: 0 })
await fetchTags()
// 排序值相同,取同名中的最后一个(最新创建)
const created = [...allTags.value].reverse().find((tag) => tag.name === pending.name)
if (created) {
addTagToForm(created.id)
}
message.success(t('ai.newTag.saved'))
pendingTag.value = null
} catch (e: any) {
message.error(e.message || t('common.operationFailed'))
} finally {
aiTagSaving.value = false
}
}
/** 将标签追加到表单已选标签中(去重) */
function addTagToForm(tagId: number) {
if (!form.value.tagIds.includes(tagId)) {
form.value.tagIds = [...form.value.tagIds, tagId]
}
}
/** 一键应用全部 AI 建议:覆盖分类并追加标签 */ /** 一键应用全部 AI 建议:覆盖分类并追加标签 */
async function handleAiApply() { async function handleAiApply() {
if (aiSuggestions.value.length === 0) { const selected = new Set(checkedAiRowKeys.value)
const chosen = aiSuggestions.value.filter((s) => selected.has(s.transactionId))
if (chosen.length === 0) {
message.warning(t('ai.applyNone'))
return return
} }
aiApplying.value = true aiApplying.value = true
try { try {
const payload = aiSuggestions.value.map((s) => ({ // 仅提交勾选的建议,聚合建议按组展开为逐条记录
transactionId: s.transactionId, const payload = chosen.flatMap((s) => {
categoryId: s.categoryId, const ids = s.transactionIds?.length ? s.transactionIds : [s.transactionId]
tagIds: s.tagIds || [], return ids.map((id) => ({ transactionId: id, categoryId: s.categoryId, tagIds: s.tagIds || [] }))
})) })
const res: any = await applyAiSuggestions(payload) const res: any = await applyAiSuggestions(payload)
message.success(t('ai.applySuccess', { count: res.data })) message.success(t('ai.applySuccess', { count: res.data }))
showAiModal.value = false showAiModal.value = false
@@ -1042,7 +1293,9 @@ const activeFilterTags = computed(() => {
}) })
filterTag.value.forEach((id) => { filterTag.value.forEach((id) => {
const name = allTags.value.find((tag) => tag.id === id)?.name || String(id) const name = id === 0
? t('transaction.untagged')
: (allTags.value.find((tag) => tag.id === id)?.name || String(id))
tags.push({ tags.push({
key: `tag:${id}`, key: `tag:${id}`,
label: name, label: name,
@@ -1075,7 +1328,7 @@ function applyTagClear(tag: { clear: () => void }) {
handleSearch() handleSearch()
} }
/** AI 建议行:合并交易记录信息用于展示 */ /** AI 建议行:合并代表交易记录信息用于展示 */
const aiRows = computed(() => const aiRows = computed(() =>
aiSuggestions.value.map((s) => ({ aiSuggestions.value.map((s) => ({
...s, ...s,
@@ -1083,28 +1336,46 @@ const aiRows = computed(() =>
})), })),
) )
/** AI 建议表格列定义(随语言切换响应式更新) */ /** 已勾选 AI 建议覆盖的记录总数(聚合组按组内记录数累加) */
const aiColumns = computed<DataTableColumns<any>>(() => [ const aiTotalCount = computed(() => {
{ const selected = new Set(checkedAiRowKeys.value)
title: t('ai.column.record'), key: 'record', minWidth: 200, return aiSuggestions.value
render: (row) => { .filter((s) => selected.has(s.transactionId))
const tx = row.transaction .reduce((sum, s) => sum + (s.transactionIds?.length || 1), 0)
if (!tx) return `#${row.transactionId}` })
const time = tx.transactionTime?.replace('T', ' ').substring(0, 16) || '-'
const sign = tx.type === 'INCOME' ? '+' : '-' /** AI 建议表格列定义(随语言切换与建议范围响应式更新) */
return h('div', null, [ const aiColumns = computed<DataTableColumns<any>>(() => {
h('div', null, `${time} ${sign}${tx.amount.toFixed(2)}`), const columns: DataTableColumns<any> = [
h('div', { style: { color: '#999', fontSize: '12px' } }, tx.remark || '-'), { type: 'selection' },
]) {
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.count'), key: 'count', width: 90, render: (row) => row.count ?? 1 },
{ title: t('ai.column.category'), key: 'category', width: 130, render: (row) => row.categoryName || '-' }, ]
{ // 按建议范围展示对应列
title: t('ai.column.tags'), key: 'tags', minWidth: 150, if (aiScope.value !== 'TAG') {
render: (row) => (row.tagNames && row.tagNames.length > 0 ? row.tagNames.join('、') : '-'), columns.push({ title: t('ai.column.category'), key: 'category', width: 130, render: (row) => row.categoryName || '-' })
}, }
{ title: t('ai.column.reason'), key: 'reason', minWidth: 180, ellipsis: { tooltip: true } }, if (aiScope.value !== 'CATEGORY') {
]) columns.push({
title: t('ai.column.tags'), key: 'tags', minWidth: 150,
render: (row) => (row.tagNames && row.tagNames.length > 0 ? row.tagNames.join('、') : '-'),
})
}
columns.push({ title: t('ai.column.reason'), key: 'reason', minWidth: 180, ellipsis: { tooltip: true } })
return columns
})
/** 打开批量设置分类弹窗 */ /** 打开批量设置分类弹窗 */
function handleBatchOpen() { function handleBatchOpen() {
@@ -1171,7 +1442,7 @@ async function handleExport() {
remark: filterRemark.value.trim() || undefined, remark: filterRemark.value.trim() || undefined,
sortField: sortField.value, sortField: sortField.value,
sortOrder: sortOrder.value, sortOrder: sortOrder.value,
tagIds: filterTag.value.length > 0 ? filterTag.value : undefined, ...buildTagFilter(),
}) })
const blob = new Blob([res.data], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' }) const blob = new Blob([res.data], { type: 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet' })
const url = window.URL.createObjectURL(blob) const url = window.URL.createObjectURL(blob)
@@ -1325,7 +1596,7 @@ onMounted(() => {
<div class="filter-label">{{ t('transaction.column.tags') }}</div> <div class="filter-label">{{ t('transaction.column.tags') }}</div>
<NSelect <NSelect
v-model:value="filterTag" v-model:value="filterTag"
:options="tagFilterOptions" :options="tagFilterSelectOptions"
multiple multiple
clearable clearable
filterable filterable
@@ -1429,8 +1700,19 @@ onMounted(() => {
<NSelect v-model:value="form.type" :options="typeOptions" /> <NSelect v-model:value="form.type" :options="typeOptions" />
</NFormItem> </NFormItem>
<NFormItem :label="t('transaction.column.category')"> <NFormItem :label="t('transaction.column.category')">
<NSelect v-model:value="form.categoryId" :options="categories.filter(c => c.type === form.type)" :render-label="renderCategoryLabel" :placeholder="t('transaction.selectCategoryPlaceholder')" filterable /> <div style="display: flex; gap: 8px; width: 100%;">
<NSelect v-model:value="form.categoryId" :options="categories.filter(c => c.type === form.type)" :render-label="renderCategoryLabel" :placeholder="t('transaction.selectCategoryPlaceholder')" filterable style="flex: 1;" />
<NButton v-if="aiEnabled" size="small" :loading="aiFormLoading" @click="handleAiSuggestOne">{{ t('ai.pickButton') }}</NButton>
<NButton v-if="aiEnabled" size="small" :loading="aiNewLoading" @click="handleAiNewCategory">{{ t('ai.newCategory.button') }}</NButton>
</div>
</NFormItem> </NFormItem>
<!-- AI 生成的新分类确认条:用户确认后才保存 -->
<div v-if="pendingCategory" style="display: flex; align-items: center; gap: 8px; margin-bottom: 18px; padding: 8px 12px; background: #f7f7fa; border-radius: 4px; font-size: 13px;">
<span>{{ t('ai.newCategory.hint') }}</span>
<span style="font-weight: 600;">{{ pendingCategory.icon }} {{ pendingCategory.name }}</span>
<NButton size="tiny" type="primary" :loading="aiNewSaving" @click="handleSaveNewCategory">{{ t('ai.newCategory.save') }}</NButton>
<NButton size="tiny" @click="pendingCategory = null">{{ t('common.cancel') }}</NButton>
</div>
<NFormItem :label="t('transaction.column.amount')"> <NFormItem :label="t('transaction.column.amount')">
<NInputNumber v-model:value="form.amount" :min="0.01" :precision="2" style="width: 100%;" :placeholder="t('transaction.amountPlaceholder')" /> <NInputNumber v-model:value="form.amount" :min="0.01" :precision="2" style="width: 100%;" :placeholder="t('transaction.amountPlaceholder')" />
</NFormItem> </NFormItem>
@@ -1444,15 +1726,27 @@ onMounted(() => {
<NInput v-model:value="form.remark" type="textarea" :placeholder="t('transaction.remarkPlaceholder')" /> <NInput v-model:value="form.remark" type="textarea" :placeholder="t('transaction.remarkPlaceholder')" />
</NFormItem> </NFormItem>
<NFormItem :label="t('transaction.column.tags')"> <NFormItem :label="t('transaction.column.tags')">
<NSelect <div style="display: flex; gap: 8px; width: 100%;">
v-model:value="form.tagIds" <NSelect
:options="tagFilterOptions" v-model:value="form.tagIds"
multiple :options="tagFilterOptions"
clearable multiple
filterable clearable
:placeholder="t('transaction.tagPlaceholder')" filterable
/> :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>
</div>
</NFormItem> </NFormItem>
<!-- AI 生成的新标签确认条:用户确认后才保存 -->
<div v-if="pendingTag" style="display: flex; align-items: center; gap: 8px; margin-bottom: 18px; padding: 8px 12px; background: #f7f7fa; border-radius: 4px; font-size: 13px;">
<span>{{ t('ai.newTag.hint') }}</span>
<NTag size="small" :color="{ color: pendingTag.color, textColor: '#fff' }">{{ pendingTag.name }}</NTag>
<NButton size="tiny" type="primary" :loading="aiTagSaving" @click="handleSaveNewTag">{{ t('ai.newTag.save') }}</NButton>
<NButton size="tiny" @click="pendingTag = null">{{ t('common.cancel') }}</NButton>
</div>
<NFormItem :label="t('transaction.includeInStats')"> <NFormItem :label="t('transaction.includeInStats')">
<NSwitch v-model:value="form.includeInStats" /> <NSwitch v-model:value="form.includeInStats" />
<span style="color: #999; margin-left: 8px; font-size: 12px;">{{ t('transaction.includeInStatsHint') }}</span> <span style="color: #999; margin-left: 8px; font-size: 12px;">{{ t('transaction.includeInStatsHint') }}</span>
@@ -1478,9 +1772,12 @@ onMounted(() => {
</span> </span>
</NFormItem> </NFormItem>
<NFormItem v-if="aiEnabled" :label="t('ai.suggestButton')"> <NFormItem v-if="aiEnabled" :label="t('ai.suggestButton')">
<NButton :loading="aiLoading" @click="handleAiSuggest">{{ t('ai.suggestButton') }}</NButton> <div style="display: flex; gap: 8px; width: 100%;">
<NSelect v-model:value="aiScope" :options="aiScopeOptions" style="flex: 1;" />
<NButton :loading="aiLoading" @click="handleAiSuggest">{{ t('ai.suggestButton') }}</NButton>
</div>
</NFormItem> </NFormItem>
<NFormItem :label="t('transaction.expenseCategory')"> <NFormItem v-if="hasExpenseRecords" :label="t('transaction.expenseCategory')">
<NSelect <NSelect
v-model:value="batchExpenseCategoryId" v-model:value="batchExpenseCategoryId"
:options="categories.filter(c => c.type === 'EXPENSE')" :options="categories.filter(c => c.type === 'EXPENSE')"
@@ -1490,7 +1787,7 @@ onMounted(() => {
:placeholder="t('transaction.notSetExpense')" :placeholder="t('transaction.notSetExpense')"
/> />
</NFormItem> </NFormItem>
<NFormItem :label="t('transaction.incomeCategory')"> <NFormItem v-if="hasIncomeRecords" :label="t('transaction.incomeCategory')">
<NSelect <NSelect
v-model:value="batchIncomeCategoryId" v-model:value="batchIncomeCategoryId"
:options="categories.filter(c => c.type === 'INCOME')" :options="categories.filter(c => c.type === 'INCOME')"
@@ -1514,10 +1811,11 @@ onMounted(() => {
</NModal> </NModal>
<!-- AI 建议弹窗(一键应用分类与标签建议) --> <!-- AI 建议弹窗(一键应用分类与标签建议) -->
<NModal v-model:show="showAiModal" preset="card" :title="t('ai.suggestTitle')" style="width: 860px; max-width: 92vw;"> <NModal v-if="aiEnabled" v-model:show="showAiModal" preset="card" :title="t('ai.suggestTitle')" style="width: 860px; max-width: 92vw;">
<NSpace vertical :size="12"> <NSpace vertical :size="12">
<NAlert type="warning" :show-icon="true">{{ t('ai.applyHint') }}</NAlert> <NAlert type="warning" :show-icon="true">{{ t('ai.applyHint') }}</NAlert>
<NDataTable <NDataTable
v-model:checked-row-keys="checkedAiRowKeys"
:columns="aiColumns" :columns="aiColumns"
:data="aiRows" :data="aiRows"
:max-height="420" :max-height="420"
@@ -1527,15 +1825,15 @@ onMounted(() => {
/> />
<NSpace justify="end"> <NSpace justify="end">
<NButton @click="showAiModal = false">{{ t('common.cancel') }}</NButton> <NButton @click="showAiModal = false">{{ t('common.cancel') }}</NButton>
<NButton type="primary" :loading="aiApplying" :disabled="aiRows.length === 0" @click="handleAiApply"> <NButton type="primary" :loading="aiApplying" :disabled="checkedAiRowKeys.length === 0" @click="handleAiApply">
{{ t('ai.applyAll', { count: aiRows.length }) }} {{ t('ai.applyAll', { count: aiTotalCount }) }}
</NButton> </NButton>
</NSpace> </NSpace>
</NSpace> </NSpace>
</NModal> </NModal>
<!-- AI 自然语言查询解析结果弹窗 --> <!-- AI 自然语言查询解析结果弹窗 -->
<NModal v-model:show="showAiQueryModal" preset="card" :title="t('ai.query.resultTitle')" style="width: 560px; max-width: 92vw;"> <NModal v-if="aiEnabled" v-model:show="showAiQueryModal" preset="card" :title="t('ai.query.resultTitle')" style="width: 560px; max-width: 92vw;">
<NSpace vertical :size="12"> <NSpace vertical :size="12">
<NAlert v-if="aiQueryResult?.summary" type="info" :show-icon="true">{{ aiQueryResult.summary }}</NAlert> <NAlert v-if="aiQueryResult?.summary" type="info" :show-icon="true">{{ aiQueryResult.summary }}</NAlert>
<NText v-if="aiQueryEmpty" depth="3">{{ t('ai.query.noCondition') }}</NText> <NText v-if="aiQueryEmpty" depth="3">{{ t('ai.query.noCondition') }}</NText>
@@ -1590,7 +1888,7 @@ onMounted(() => {
</NModal> </NModal>
<!-- AI 智能记账弹窗 --> <!-- AI 智能记账弹窗 -->
<NModal v-model:show="showAiRecordModal" preset="card" :title="t('ai.record.title')" style="width: 520px; max-width: 92vw;"> <NModal v-if="aiEnabled" v-model:show="showAiRecordModal" preset="card" :title="t('ai.record.title')" style="width: 520px; max-width: 92vw;">
<NSpace vertical :size="12"> <NSpace vertical :size="12">
<NAlert type="info" :show-icon="true">{{ t('ai.record.hint') }}</NAlert> <NAlert type="info" :show-icon="true">{{ t('ai.record.hint') }}</NAlert>
<NInput <NInput
@@ -2,19 +2,28 @@ package cn.rosercode.finance.controller;
import cn.rosercode.finance.common.Result; import cn.rosercode.finance.common.Result;
import cn.rosercode.finance.dto.AiApplyRequest; import cn.rosercode.finance.dto.AiApplyRequest;
import cn.rosercode.finance.dto.AiCategoryRequest;
import cn.rosercode.finance.dto.AiConfigRequest; import cn.rosercode.finance.dto.AiConfigRequest;
import cn.rosercode.finance.dto.AiDecorateRequest;
import cn.rosercode.finance.dto.AiQueryRequest; import cn.rosercode.finance.dto.AiQueryRequest;
import cn.rosercode.finance.dto.AiRecordRequest; import cn.rosercode.finance.dto.AiRecordRequest;
import cn.rosercode.finance.dto.AiSuggestOneRequest;
import cn.rosercode.finance.dto.AiSuggestRequest; import cn.rosercode.finance.dto.AiSuggestRequest;
import cn.rosercode.finance.dto.AiTagRequest;
import cn.rosercode.finance.service.AiCategoryService;
import cn.rosercode.finance.service.AiConfigService; import cn.rosercode.finance.service.AiConfigService;
import cn.rosercode.finance.service.AiDecorationService;
import cn.rosercode.finance.service.AiQueryService; import cn.rosercode.finance.service.AiQueryService;
import cn.rosercode.finance.service.AiRecordService; import cn.rosercode.finance.service.AiRecordService;
import cn.rosercode.finance.service.AiSuggestionService; import cn.rosercode.finance.service.AiSuggestionService;
import cn.rosercode.finance.service.AiTagService;
import cn.rosercode.finance.service.AiUsageService; import cn.rosercode.finance.service.AiUsageService;
import cn.rosercode.finance.vo.AiCategoryResultVO;
import cn.rosercode.finance.vo.AiConfigVO; import cn.rosercode.finance.vo.AiConfigVO;
import cn.rosercode.finance.vo.AiQueryResultVO; import cn.rosercode.finance.vo.AiQueryResultVO;
import cn.rosercode.finance.vo.AiRecordResultVO; import cn.rosercode.finance.vo.AiRecordResultVO;
import cn.rosercode.finance.vo.AiSuggestionVO; import cn.rosercode.finance.vo.AiSuggestionVO;
import cn.rosercode.finance.vo.AiTagResultVO;
import cn.rosercode.finance.vo.AiUsageResultVO; import cn.rosercode.finance.vo.AiUsageResultVO;
import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag; import io.swagger.v3.oas.annotations.tags.Tag;
@@ -47,15 +56,22 @@ public class AiController {
private final AiUsageService aiUsageService; private final AiUsageService aiUsageService;
private final AiQueryService aiQueryService; private final AiQueryService aiQueryService;
private final AiRecordService aiRecordService; private final AiRecordService aiRecordService;
private final AiDecorationService aiDecorationService;
private final AiCategoryService aiCategoryService;
private final AiTagService aiTagService;
public AiController(AiConfigService aiConfigService, AiSuggestionService aiSuggestionService, public AiController(AiConfigService aiConfigService, AiSuggestionService aiSuggestionService,
AiUsageService aiUsageService, AiQueryService aiQueryService, AiUsageService aiUsageService, AiQueryService aiQueryService,
AiRecordService aiRecordService) { AiRecordService aiRecordService, AiDecorationService aiDecorationService,
AiCategoryService aiCategoryService, AiTagService aiTagService) {
this.aiConfigService = aiConfigService; this.aiConfigService = aiConfigService;
this.aiSuggestionService = aiSuggestionService; this.aiSuggestionService = aiSuggestionService;
this.aiUsageService = aiUsageService; this.aiUsageService = aiUsageService;
this.aiQueryService = aiQueryService; this.aiQueryService = aiQueryService;
this.aiRecordService = aiRecordService; this.aiRecordService = aiRecordService;
this.aiDecorationService = aiDecorationService;
this.aiCategoryService = aiCategoryService;
this.aiTagService = aiTagService;
} }
/** /**
@@ -112,7 +128,22 @@ public class AiController {
public Result<List<AiSuggestionVO>> suggest(HttpServletRequest request, public Result<List<AiSuggestionVO>> suggest(HttpServletRequest request,
@RequestBody @Valid AiSuggestRequest suggestRequest) { @RequestBody @Valid AiSuggestRequest suggestRequest) {
Long userId = (Long) request.getAttribute("userId"); Long userId = (Long) request.getAttribute("userId");
return Result.success(aiSuggestionService.suggest(userId, suggestRequest.getIds())); return Result.success(aiSuggestionService.suggest(userId, suggestRequest.getIds(), suggestRequest.getScope()));
}
/**
* 根据表单当前内容推荐分类与标签
*
* @param request HTTP请求,从中获取用户ID
* @param suggestOneRequest 建议请求,携带表单当前内容
* @return 单条建议结果
*/
@PostMapping("/suggest-one")
@Operation(summary = "单条记录 AI 建议", description = "根据表单当前内容推荐分类与标签,供新增或编辑时参考")
public Result<AiSuggestionVO> suggestOne(HttpServletRequest request,
@RequestBody @Valid AiSuggestOneRequest suggestOneRequest) {
Long userId = (Long) request.getAttribute("userId");
return Result.success(aiSuggestionService.suggestOne(userId, suggestOneRequest));
} }
/** /**
@@ -174,4 +205,64 @@ public class AiController {
Long userId = (Long) request.getAttribute("userId"); Long userId = (Long) request.getAttribute("userId");
return Result.success(aiRecordService.parse(userId, recordRequest.getText())); return Result.success(aiRecordService.parse(userId, recordRequest.getText()));
} }
/**
* 根据自然语言描述生成分类
*
* @param request HTTP请求,从中获取用户ID
* @param categoryRequest 生成请求,携带分类用途描述
* @return 分类建议
*/
@PostMapping("/category")
@Operation(summary = "AI 生成分类", description = "根据自然语言描述生成分类名称、类型与图标")
public Result<AiCategoryResultVO> generateCategory(HttpServletRequest request,
@RequestBody @Valid AiCategoryRequest categoryRequest) {
Long userId = (Long) request.getAttribute("userId");
return Result.success(aiCategoryService.parse(userId, categoryRequest.getText()));
}
/**
* 根据自然语言描述生成标签
*
* @param request HTTP请求,从中获取用户ID
* @param tagRequest 生成请求,携带标签用途描述
* @return 标签建议
*/
@PostMapping("/tag")
@Operation(summary = "AI 生成标签", description = "根据自然语言描述生成标签名称与颜色")
public Result<AiTagResultVO> generateTag(HttpServletRequest request,
@RequestBody @Valid AiTagRequest tagRequest) {
Long userId = (Long) request.getAttribute("userId");
return Result.success(aiTagService.parse(userId, tagRequest.getText()));
}
/**
* 根据分类名称推荐 Emoji 图标
*
* @param request HTTP请求,从中获取用户ID
* @param decorateRequest 建议请求,携带分类名称与收支类型
* @return 推荐的 Emoji
*/
@PostMapping("/emoji")
@Operation(summary = "AI 推荐分类图标", description = "根据分类名称与收支类型推荐一个 Emoji")
public Result<String> suggestEmoji(HttpServletRequest request,
@RequestBody @Valid AiDecorateRequest decorateRequest) {
Long userId = (Long) request.getAttribute("userId");
return Result.success(aiDecorationService.suggestEmoji(userId, decorateRequest.getName(), decorateRequest.getType()));
}
/**
* 根据标签名称推荐颜色
*
* @param request HTTP请求,从中获取用户ID
* @param decorateRequest 建议请求,携带标签名称
* @return 推荐的十六进制颜色
*/
@PostMapping("/color")
@Operation(summary = "AI 推荐标签颜色", description = "根据标签名称推荐一个十六进制颜色")
public Result<String> suggestColor(HttpServletRequest request,
@RequestBody @Valid AiDecorateRequest decorateRequest) {
Long userId = (Long) request.getAttribute("userId");
return Result.success(aiDecorationService.suggestColor(userId, decorateRequest.getName()));
}
} }
@@ -0,0 +1,17 @@
package cn.rosercode.finance.dto;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
/**
* AI 生成分类请求 DTO,携带用户对分类用途的自然语言描述。
*
* @date 2026-09-18 09:29:33
*/
@Data
public class AiCategoryRequest {
/** 分类用途的自然语言描述 */
@NotBlank(message = "请输入分类描述")
private String text;
}
@@ -0,0 +1,20 @@
package cn.rosercode.finance.dto;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
/**
* 分类图标与标签配色建议请求 DTO,携带用于生成建议的名称与收支类型。
*
* @date 2026-09-18 09:00:44
*/
@Data
public class AiDecorateRequest {
/** 分类或标签名称 */
@NotBlank(message = "名称不能为空")
private String name;
/** 收支类型,仅分类图标建议使用(INCOME/EXPENSE) */
private String type;
}
@@ -0,0 +1,28 @@
package cn.rosercode.finance.dto;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
import java.math.BigDecimal;
/**
* 单条记录 AI 建议请求 DTO,携带表单当前内容用于生成分类与标签建议。
*
* @date 2026-09-18 09:21:48
*/
@Data
public class AiSuggestOneRequest {
/** 交易类型(INCOME/EXPENSE) */
@NotBlank(message = "交易类型不能为空")
private String type;
/** 交易金额 */
private BigDecimal amount;
/** 支付方式 */
private String paymentMethod;
/** 备注 */
private String remark;
}
@@ -16,4 +16,7 @@ public class AiSuggestRequest {
/** 待分析建议的交易记录ID列表 */ /** 待分析建议的交易记录ID列表 */
@NotEmpty(message = "请至少选择一条记录") @NotEmpty(message = "请至少选择一条记录")
private List<Long> ids; private List<Long> ids;
/** 建议范围:BOTH-分类与标签(默认),CATEGORY-仅分类,TAG-仅标签 */
private String scope;
} }
@@ -0,0 +1,17 @@
package cn.rosercode.finance.dto;
import jakarta.validation.constraints.NotBlank;
import lombok.Data;
/**
* AI 生成标签请求 DTO,携带用户对标签用途的自然语言描述。
*
* @date 2026-09-18 09:39:25
*/
@Data
public class AiTagRequest {
/** 标签用途的自然语言描述 */
@NotBlank(message = "请输入标签描述")
private String text;
}
@@ -55,4 +55,7 @@ public class TransactionQuery {
/** 标签ID筛选,支持多选 */ /** 标签ID筛选,支持多选 */
private List<Long> tagIds; private List<Long> tagIds;
/** 是否仅筛选无标签记录;与 tagIds 同时存在时取并集 */
private Boolean untagged;
} }
@@ -15,8 +15,7 @@ import java.util.List;
@Data @Data
public class TransactionRequest { public class TransactionRequest {
/** 分类ID,不能为空 */ /** 分类ID,可选;为空表示未分类 */
@NotNull(message = "分类不能为空")
private Long categoryId; private Long categoryId;
/** 交易类型(收入/支出),不能为空 */ /** 交易类型(收入/支出),不能为空 */
@@ -17,5 +17,14 @@ public enum AiCallType {
QUERY, QUERY,
/** 自然语言记账解析 */ /** 自然语言记账解析 */
RECORD RECORD,
/** 分类图标与标签配色建议 */
DECORATE,
/** 自然语言生成分类 */
CATEGORY,
/** 自然语言生成标签 */
TAG
} }
@@ -0,0 +1,20 @@
package cn.rosercode.finance.service;
import cn.rosercode.finance.vo.AiCategoryResultVO;
/**
* AI 生成分类服务接口,将自然语言描述解析为分类建议。
*
* @date 2026-09-18 09:29:33
*/
public interface AiCategoryService {
/**
* 根据自然语言描述生成分类建议。
*
* @param userId 用户ID
* @param text 分类用途描述
* @return 分类建议
*/
AiCategoryResultVO parse(Long userId, String text);
}
@@ -0,0 +1,28 @@
package cn.rosercode.finance.service;
/**
* AI 装饰建议服务接口,根据分类/标签名称生成图标与配色建议。
*
* @date 2026-09-18 09:00:44
*/
public interface AiDecorationService {
/**
* 根据分类名称与收支类型推荐一个 Emoji 图标。
*
* @param userId 用户ID
* @param name 分类名称
* @param type 收支类型(INCOME/EXPENSE),可为空
* @return 推荐的 Emoji
*/
String suggestEmoji(Long userId, String name, String type);
/**
* 根据标签名称推荐一个十六进制颜色。
*
* @param userId 用户ID
* @param name 标签名称
* @return 推荐的颜色(如 #18a058)
*/
String suggestColor(Long userId, String name);
}
@@ -1,6 +1,7 @@
package cn.rosercode.finance.service; package cn.rosercode.finance.service;
import cn.rosercode.finance.dto.AiApplyRequest; import cn.rosercode.finance.dto.AiApplyRequest;
import cn.rosercode.finance.dto.AiSuggestOneRequest;
import cn.rosercode.finance.vo.AiSuggestionVO; import cn.rosercode.finance.vo.AiSuggestionVO;
import java.util.List; import java.util.List;
@@ -17,9 +18,19 @@ public interface AiSuggestionService {
* *
* @param userId 用户ID * @param userId 用户ID
* @param ids 待分析的建议记录ID列表 * @param ids 待分析的建议记录ID列表
* @param scope 建议范围:BOTH-分类与标签,CATEGORY-仅分类,TAG-仅标签
* @return 逐条建议列表 * @return 逐条建议列表
*/ */
List<AiSuggestionVO> suggest(Long userId, List<Long> ids); List<AiSuggestionVO> suggest(Long userId, List<Long> ids, String scope);
/**
* 根据表单当前内容(尚未保存的记录)生成分类与标签建议。
*
* @param userId 用户ID
* @param request 单条记录内容
* @return 建议结果,无有效建议时返回 null
*/
AiSuggestionVO suggestOne(Long userId, AiSuggestOneRequest request);
/** /**
* 一键应用建议:覆盖分类、追加标签。 * 一键应用建议:覆盖分类、追加标签。
@@ -0,0 +1,20 @@
package cn.rosercode.finance.service;
import cn.rosercode.finance.vo.AiTagResultVO;
/**
* AI 生成标签服务接口,将自然语言描述解析为标签建议。
*
* @date 2026-09-18 09:39:25
*/
public interface AiTagService {
/**
* 根据自然语言描述生成标签建议。
*
* @param userId 用户ID
* @param text 标签用途描述
* @return 标签建议
*/
AiTagResultVO parse(Long userId, String text);
}
@@ -0,0 +1,174 @@
package cn.rosercode.finance.service.impl;
import cn.rosercode.finance.client.DeepSeekClient;
import cn.rosercode.finance.client.DeepSeekResponse;
import cn.rosercode.finance.common.BusinessException;
import cn.rosercode.finance.entity.AiConfig;
import cn.rosercode.finance.enums.AiCallType;
import cn.rosercode.finance.service.AiCategoryService;
import cn.rosercode.finance.service.AiConfigService;
import cn.rosercode.finance.service.AiUsageService;
import cn.rosercode.finance.vo.AiCategoryResultVO;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.stereotype.Service;
import java.util.List;
/**
* AI 生成分类服务实现类,根据自然语言描述生成分类名称、类型与图标。
*
* @date 2026-09-18 09:29:33
*/
@Service
public class AiCategoryServiceImpl implements AiCategoryService {
/** 分类名称最大长度 */
private static final int NAME_MAX_LENGTH = 20;
/** 图标最大长度(按 UTF-16 字符数限制) */
private static final int ICON_MAX_LENGTH = 16;
/** 合法的收支类型 */
private static final List<String> VALID_TYPES = List.of("INCOME", "EXPENSE");
/** 系统提示词,要求模型严格返回 JSON */
private static final String SYSTEM_PROMPT = "你是一个个人记账应用的分类助手。"
+ "请根据用户对分类用途的自然语言描述,生成一个简洁的记账分类。"
+ "要求:1. name 为 2-6 个汉字的分类名称;2. type 只能是 INCOME(收入)或 EXPENSE(支出);"
+ "3. icon 为与该分类最贴切的一个 Emoji;4. 只输出 JSON,不要输出任何多余内容。"
+ "输出格式:{\"name\":\"交通\",\"type\":\"EXPENSE\",\"icon\":\"🚌\",\"reason\":\"简要中文理由\"}";
private final AiConfigService aiConfigService;
private final DeepSeekClient deepSeekClient;
private final AiUsageService aiUsageService;
private final ObjectMapper objectMapper;
public AiCategoryServiceImpl(AiConfigService aiConfigService,
DeepSeekClient deepSeekClient,
AiUsageService aiUsageService,
ObjectMapper objectMapper) {
this.aiConfigService = aiConfigService;
this.deepSeekClient = deepSeekClient;
this.aiUsageService = aiUsageService;
this.objectMapper = objectMapper;
}
/**
* {@inheritDoc}
*
* 将用户描述直接作为提示词内容,解析模型返回的分类名称、类型与图标。
*/
@Override
public AiCategoryResultVO parse(Long userId, String text) {
if (text == null || text.isBlank()) {
throw new BusinessException("请输入分类描述");
}
AiConfig config = requireConfig(userId);
long start = System.currentTimeMillis();
DeepSeekResponse aiResponse;
try {
aiResponse = deepSeekClient.chat(config.getBaseUrl(), config.getApiKey(), config.getModel(),
SYSTEM_PROMPT, text.trim());
} catch (RuntimeException e) {
// 失败也记录一次使用,便于排查问题
aiUsageService.recordFailure(userId, AiCallType.CATEGORY, config.getModel(), 0,
System.currentTimeMillis() - start, e.getMessage());
throw e;
}
aiUsageService.recordSuccess(userId, AiCallType.CATEGORY, config.getModel(), 0,
aiResponse, System.currentTimeMillis() - start);
return parseResult(aiResponse.getContent());
}
/**
* 解析模型返回的 JSON 并校验字段合法性。
*
* @param content 模型输出内容
* @return 分类建议
*/
private AiCategoryResultVO parseResult(String content) {
JsonNode root;
try {
root = objectMapper.readTree(stripCodeFence(content));
} catch (JsonProcessingException e) {
throw new BusinessException("AI 返回内容不是有效 JSON,请重试");
}
String name = root.path("name").asText("").trim();
if (isBlank(name) || name.length() > NAME_MAX_LENGTH) {
throw new BusinessException("AI 未返回有效的分类名称,请重试");
}
// 类型非法时回退为支出
String type = root.path("type").asText("EXPENSE").trim().toUpperCase();
if (!VALID_TYPES.contains(type)) {
type = "EXPENSE";
}
String icon = root.path("icon").asText("").trim();
if (icon.length() > ICON_MAX_LENGTH) {
icon = "";
}
AiCategoryResultVO vo = new AiCategoryResultVO();
vo.setName(name);
vo.setType(type);
vo.setIcon(icon);
vo.setReason(root.path("reason").asText(""));
return vo;
}
/**
* 获取并校验当前用户的 AI 配置。
*
* @param userId 用户ID
* @return 有效的 AI 配置
*/
private AiConfig requireConfig(Long userId) {
AiConfig config = aiConfigService.getRawConfig(userId);
if (config == null || !Boolean.TRUE.equals(config.getEnabled())) {
throw new BusinessException("AI 功能未启用,请先在 AI 设置中开启");
}
if (isBlank(config.getApiKey())) {
throw new BusinessException("请先配置 DeepSeek API Key");
}
return config;
}
/**
* 去除模型可能附加的 Markdown 代码块围栏。
*
* @param content 原始内容
* @return 清理后的内容
*/
private String stripCodeFence(String content) {
if (content == null) {
return "";
}
String cleaned = content.trim();
if (cleaned.startsWith("```")) {
int firstLineEnd = cleaned.indexOf('\n');
if (firstLineEnd > 0) {
cleaned = cleaned.substring(firstLineEnd + 1);
}
if (cleaned.endsWith("```")) {
cleaned = cleaned.substring(0, cleaned.length() - 3);
}
}
return cleaned.trim();
}
/**
* 判断字符串是否为空白。
*
* @param value 待判断字符串
* @return 为 null 或全空白时返回 true
*/
private boolean isBlank(String value) {
return value == null || value.isBlank();
}
}
@@ -0,0 +1,202 @@
package cn.rosercode.finance.service.impl;
import cn.rosercode.finance.client.DeepSeekClient;
import cn.rosercode.finance.client.DeepSeekResponse;
import cn.rosercode.finance.common.BusinessException;
import cn.rosercode.finance.entity.AiConfig;
import cn.rosercode.finance.enums.AiCallType;
import cn.rosercode.finance.service.AiConfigService;
import cn.rosercode.finance.service.AiDecorationService;
import cn.rosercode.finance.service.AiUsageService;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.stereotype.Service;
import java.util.regex.Pattern;
/**
* AI 装饰建议服务实现类,根据分类/标签名称调用模型生成 Emoji 与配色建议。
*
* @date 2026-09-18 09:00:44
*/
@Service
public class AiDecorationServiceImpl implements AiDecorationService {
/** Emoji 建议提示词 */
private static final String EMOJI_SYSTEM_PROMPT = "你是一个个人记账应用的图标助手。"
+ "请根据给定的分类名称与收支类型,从常用 Emoji 中挑选一个最贴切、辨识度高的作为分类图标。"
+ "只输出 JSON,不要输出任何多余内容。输出格式:{\"emoji\":\"🍜\"}";
/** 颜色建议提示词 */
private static final String COLOR_SYSTEM_PROMPT = "你是一个个人记账应用的配色助手。"
+ "请根据标签名称的语义,挑选一个高饱和、适合作为标签背景色且搭配白色文字清晰可读的十六进制颜色。"
+ "只输出 JSON,不要输出任何多余内容。输出格式:{\"color\":\"#18a058\"}";
/** Emoji 最大长度(按 UTF-16 字符数限制,兼容组合 Emoji) */
private static final int EMOJI_MAX_LENGTH = 16;
/** 十六进制颜色校验 */
private static final Pattern COLOR_PATTERN = Pattern.compile("^#[0-9a-fA-F]{6}$");
private final AiConfigService aiConfigService;
private final DeepSeekClient deepSeekClient;
private final AiUsageService aiUsageService;
private final ObjectMapper objectMapper;
public AiDecorationServiceImpl(AiConfigService aiConfigService,
DeepSeekClient deepSeekClient,
AiUsageService aiUsageService,
ObjectMapper objectMapper) {
this.aiConfigService = aiConfigService;
this.deepSeekClient = deepSeekClient;
this.aiUsageService = aiUsageService;
this.objectMapper = objectMapper;
}
/**
* {@inheritDoc}
*
* 将分类名称与收支类型注入提示词,解析模型返回的 Emoji 并校验长度。
*/
@Override
public String suggestEmoji(Long userId, String name, String type) {
String trimmed = requireName(name);
AiConfig config = requireConfig(userId);
String userPrompt = "分类名称:" + trimmed + ";收支类型:" + typeLabel(type);
JsonNode root = chatAndParse(userId, config, EMOJI_SYSTEM_PROMPT, userPrompt);
String emoji = root.path("emoji").asText("").trim();
if (isBlank(emoji) || emoji.length() > EMOJI_MAX_LENGTH) {
throw new BusinessException("AI 未返回有效的 Emoji,请重试");
}
return emoji;
}
/**
* {@inheritDoc}
*
* 将标签名称注入提示词,解析模型返回的颜色并校验十六进制格式。
*/
@Override
public String suggestColor(Long userId, String name) {
String trimmed = requireName(name);
AiConfig config = requireConfig(userId);
String userPrompt = "标签名称:" + trimmed;
JsonNode root = chatAndParse(userId, config, COLOR_SYSTEM_PROMPT, userPrompt);
String color = root.path("color").asText("").trim();
if (!COLOR_PATTERN.matcher(color).matches()) {
throw new BusinessException("AI 未返回有效的颜色,请重试");
}
return color.toLowerCase();
}
/**
* 调用模型并解析返回的 JSON,同时记录使用情况。
*
* @param userId 用户ID
* @param config AI 配置
* @param systemPrompt 系统提示词
* @param userPrompt 用户提示词
* @return 解析后的 JSON 根节点
*/
private JsonNode chatAndParse(Long userId, AiConfig config, String systemPrompt, String userPrompt) {
long start = System.currentTimeMillis();
DeepSeekResponse aiResponse;
try {
aiResponse = deepSeekClient.chat(config.getBaseUrl(), config.getApiKey(), config.getModel(),
systemPrompt, userPrompt);
} catch (RuntimeException e) {
// 失败也记录一次使用,便于排查问题
aiUsageService.recordFailure(userId, AiCallType.DECORATE, config.getModel(), 0,
System.currentTimeMillis() - start, e.getMessage());
throw e;
}
aiUsageService.recordSuccess(userId, AiCallType.DECORATE, config.getModel(), 0,
aiResponse, System.currentTimeMillis() - start);
try {
return objectMapper.readTree(stripCodeFence(aiResponse.getContent()));
} catch (JsonProcessingException e) {
throw new BusinessException("AI 返回内容不是有效 JSON,请重试");
}
}
/**
* 获取并校验当前用户的 AI 配置。
*
* @param userId 用户ID
* @return 有效的 AI 配置
*/
private AiConfig requireConfig(Long userId) {
AiConfig config = aiConfigService.getRawConfig(userId);
if (config == null || !Boolean.TRUE.equals(config.getEnabled())) {
throw new BusinessException("AI 功能未启用,请先在 AI 设置中开启");
}
if (isBlank(config.getApiKey())) {
throw new BusinessException("请先配置 DeepSeek API Key");
}
return config;
}
/**
* 校验名称非空。
*
* @param name 名称
* @return 去除首尾空白后的名称
*/
private String requireName(String name) {
if (isBlank(name)) {
throw new BusinessException("请先输入名称");
}
return name.trim();
}
/**
* 将收支类型转换为中文描述。
*
* @param type 收支类型
* @return 中文描述
*/
private String typeLabel(String type) {
if ("INCOME".equalsIgnoreCase(type)) {
return "收入";
}
if ("EXPENSE".equalsIgnoreCase(type)) {
return "支出";
}
return "未指定";
}
/**
* 去除模型可能附加的 Markdown 代码块围栏。
*
* @param content 原始内容
* @return 清理后的内容
*/
private String stripCodeFence(String content) {
if (content == null) {
return "";
}
String cleaned = content.trim();
if (cleaned.startsWith("```")) {
int firstLineEnd = cleaned.indexOf('\n');
if (firstLineEnd > 0) {
cleaned = cleaned.substring(firstLineEnd + 1);
}
if (cleaned.endsWith("```")) {
cleaned = cleaned.substring(0, cleaned.length() - 3);
}
}
return cleaned.trim();
}
/**
* 判断字符串是否为空白。
*
* @param value 待判断字符串
* @return 为 null 或全空白时返回 true
*/
private boolean isBlank(String value) {
return value == null || value.isBlank();
}
}
@@ -6,6 +6,7 @@ import cn.rosercode.finance.client.DeepSeekClient;
import cn.rosercode.finance.client.DeepSeekResponse; import cn.rosercode.finance.client.DeepSeekResponse;
import cn.rosercode.finance.common.BusinessException; import cn.rosercode.finance.common.BusinessException;
import cn.rosercode.finance.dto.AiApplyRequest; import cn.rosercode.finance.dto.AiApplyRequest;
import cn.rosercode.finance.dto.AiSuggestOneRequest;
import cn.rosercode.finance.entity.AiConfig; import cn.rosercode.finance.entity.AiConfig;
import cn.rosercode.finance.entity.Category; import cn.rosercode.finance.entity.Category;
import cn.rosercode.finance.entity.Tag; import cn.rosercode.finance.entity.Tag;
@@ -26,10 +27,12 @@ import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional; import org.springframework.transaction.annotation.Transactional;
import java.math.BigDecimal;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.LinkedHashMap; import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Objects;
import java.util.Set; import java.util.Set;
import java.util.function.Function; import java.util.function.Function;
import java.util.stream.Collectors; import java.util.stream.Collectors;
@@ -42,9 +45,12 @@ import java.util.stream.Collectors;
@Service @Service
public class AiSuggestionServiceImpl implements AiSuggestionService { public class AiSuggestionServiceImpl implements AiSuggestionService {
/** 单次最多分析的记录数 */ /** 单批最多分析的记录数 */
private static final int MAX_SUGGEST_SIZE = 100; private static final int MAX_SUGGEST_SIZE = 100;
/** 单次请求最多分析的记录总数(与前端最大页大小一致) */
private static final int MAX_TOTAL_SIZE = 500;
/** 原始账单数据注入提示词时的最大长度 */ /** 原始账单数据注入提示词时的最大长度 */
private static final int RAW_DATA_LIMIT = 300; private static final int RAW_DATA_LIMIT = 300;
@@ -86,28 +92,38 @@ public class AiSuggestionServiceImpl implements AiSuggestionService {
/** /**
* {@inheritDoc} * {@inheritDoc}
* *
* 校验配置与数量后,将分类、标签与交易记录注入提示词,调用模型并解析校验结果。 * 先按「类型 + 支付方式 + 备注」聚合重复记录,仅将每组代表送入模型,
* 再把建议套用到整组记录;代表数超过单批上限时自动分批调用并汇总结果。
*/ */
@Override @Override
public List<AiSuggestionVO> suggest(Long userId, List<Long> ids) { public List<AiSuggestionVO> suggest(Long userId, List<Long> ids, String scope) {
if (ids == null || ids.isEmpty()) { if (ids == null || ids.isEmpty()) {
throw new BusinessException("请至少选择一条记录"); throw new BusinessException("请至少选择一条记录");
} }
if (ids.size() > MAX_SUGGEST_SIZE) { // 建议范围:默认分类与标签都给出
throw new BusinessException("单次最多分析 " + MAX_SUGGEST_SIZE + " 条记录,请缩小范围后重试"); String normalizedScope = normalizeScope(scope);
String systemPrompt = buildSystemPrompt(normalizedScope);
List<Long> distinctIds = ids.stream()
.filter(Objects::nonNull)
.distinct()
.collect(Collectors.toList());
if (distinctIds.isEmpty()) {
throw new BusinessException("请至少选择一条记录");
} }
AiConfig config = aiConfigService.getRawConfig(userId); if (distinctIds.size() > MAX_TOTAL_SIZE) {
if (config == null || !Boolean.TRUE.equals(config.getEnabled())) { throw new BusinessException("单次最多分析 " + MAX_TOTAL_SIZE + " 条记录,请缩小范围后重试");
throw new BusinessException("AI 智能建议未启用,请先在 AI 设置中开启");
}
if (isBlank(config.getApiKey())) {
throw new BusinessException("请先配置 DeepSeek API Key");
} }
AiConfig config = requireConfig(userId);
// 仅分析属于当前用户的记录 // 仅分析属于当前用户的记录,并按请求顺序排列
List<Transaction> transactions = transactionMapper.selectList(new LambdaQueryWrapper<Transaction>() Map<Long, Transaction> transactionMap = transactionMapper.selectList(new LambdaQueryWrapper<Transaction>()
.eq(Transaction::getUserId, userId) .eq(Transaction::getUserId, userId)
.in(Transaction::getId, ids)); .in(Transaction::getId, distinctIds))
.stream().collect(Collectors.toMap(Transaction::getId, Function.identity()));
List<Transaction> transactions = distinctIds.stream()
.map(transactionMap::get)
.filter(Objects::nonNull)
.collect(Collectors.toList());
if (transactions.isEmpty()) { if (transactions.isEmpty()) {
throw new BusinessException("未找到可分析的交易记录"); throw new BusinessException("未找到可分析的交易记录");
} }
@@ -120,26 +136,258 @@ public class AiSuggestionServiceImpl implements AiSuggestionService {
List<Tag> tags = tagMapper.selectList(new LambdaQueryWrapper<Tag>() List<Tag> tags = tagMapper.selectList(new LambdaQueryWrapper<Tag>()
.eq(Tag::getUserId, userId)); .eq(Tag::getUserId, userId));
String userPrompt = buildUserPrompt(categories, tags, transactions); // 按类型、支付方式与备注聚合,重复记录只保留一条代表参与分析
List<List<Transaction>> groups = groupTransactions(transactions);
List<AiSuggestionVO> result = new ArrayList<>();
// 代表记录仍可能超过单批上限,按批调用并汇总
for (List<List<Transaction>> batch : partition(groups, MAX_SUGGEST_SIZE)) {
List<Transaction> representatives = batch.stream()
.map(group -> group.get(0))
.collect(Collectors.toList());
String userPrompt = buildUserPrompt(categories, tags, representatives);
long start = System.currentTimeMillis();
DeepSeekResponse aiResponse;
try {
aiResponse = deepSeekClient.chat(config.getBaseUrl(), config.getApiKey(), config.getModel(),
systemPrompt, userPrompt);
} catch (RuntimeException e) {
// 失败也记录一次使用,便于排查问题
aiUsageService.recordFailure(userId, AiCallType.SUGGEST, config.getModel(), representatives.size(),
System.currentTimeMillis() - start, e.getMessage());
throw e;
}
aiUsageService.recordSuccess(userId, AiCallType.SUGGEST, config.getModel(), representatives.size(),
aiResponse, System.currentTimeMillis() - start);
List<AiSuggestionVO> batchResult = parseSuggestions(aiResponse.getContent(), representatives, categories,
tags, normalizedScope);
// 将代表建议套用到同组的所有记录
Map<Long, AiSuggestionVO> byRepresentative = batchResult.stream()
.collect(Collectors.toMap(AiSuggestionVO::getTransactionId, Function.identity(), (a, b) -> a));
for (List<Transaction> group : batch) {
AiSuggestionVO suggestion = byRepresentative.get(group.get(0).getId());
if (suggestion == null) {
continue;
}
List<Long> groupIds = group.stream().map(Transaction::getId).collect(Collectors.toList());
suggestion.setTransactionIds(groupIds);
suggestion.setCount(groupIds.size());
result.add(suggestion);
}
}
if (result.isEmpty()) {
throw new BusinessException("AI 未返回有效建议,请重试");
}
return result;
}
/**
* {@inheritDoc}
*
* 以表单当前内容构造一条未落库的临时记录,仅请求一条建议。
*/
@Override
public AiSuggestionVO suggestOne(Long userId, AiSuggestOneRequest request) {
if (!"INCOME".equals(request.getType()) && !"EXPENSE".equals(request.getType())) {
throw new BusinessException("交易类型无效");
}
AiConfig config = requireConfig(userId);
List<Category> categories = categoryMapper.selectList(new LambdaQueryWrapper<Category>()
.eq(Category::getUserId, userId));
if (categories.isEmpty()) {
throw new BusinessException("请先创建分类后再使用 AI 建议");
}
List<Tag> tags = tagMapper.selectList(new LambdaQueryWrapper<Tag>()
.eq(Tag::getUserId, userId));
// 构造临时记录用于分析,ID 固定为 0,不落库
Transaction transaction = new Transaction();
transaction.setId(0L);
transaction.setUserId(userId);
transaction.setType(request.getType());
transaction.setAmount(request.getAmount());
transaction.setPaymentMethod(request.getPaymentMethod());
transaction.setRemark(request.getRemark());
List<Transaction> single = List.of(transaction);
String userPrompt = buildUserPrompt(categories, tags, single);
long start = System.currentTimeMillis(); long start = System.currentTimeMillis();
DeepSeekResponse aiResponse; DeepSeekResponse aiResponse;
try { try {
aiResponse = deepSeekClient.chat(config.getBaseUrl(), config.getApiKey(), config.getModel(), aiResponse = deepSeekClient.chat(config.getBaseUrl(), config.getApiKey(), config.getModel(),
SYSTEM_PROMPT, userPrompt); SYSTEM_PROMPT, userPrompt);
} catch (RuntimeException e) { } catch (RuntimeException e) {
// 失败也记录一次使用,便于排查问题 aiUsageService.recordFailure(userId, AiCallType.SUGGEST, config.getModel(), 1,
aiUsageService.recordFailure(userId, AiCallType.SUGGEST, config.getModel(), transactions.size(),
System.currentTimeMillis() - start, e.getMessage()); System.currentTimeMillis() - start, e.getMessage());
throw e; throw e;
} }
aiUsageService.recordSuccess(userId, AiCallType.SUGGEST, config.getModel(), transactions.size(), aiUsageService.recordSuccess(userId, AiCallType.SUGGEST, config.getModel(), 1,
aiResponse, System.currentTimeMillis() - start); aiResponse, System.currentTimeMillis() - start);
List<AiSuggestionVO> result = parseSuggestions(aiResponse.getContent(), transactions, categories, tags); return parseSingleSuggestion(aiResponse.getContent(), transaction, categories, tags);
if (result.isEmpty()) { }
throw new BusinessException("AI 未返回有效建议,请重试");
/**
* 解析模型针对单条记录返回的建议,不依赖模型回传的记录ID。
*
* @param content 模型输出内容
* @param transaction 临时记录
* @param categories 用户分类列表
* @param tags 用户标签列表
* @return 建议结果,无有效建议时返回 null
*/
private AiSuggestionVO parseSingleSuggestion(String content,
Transaction transaction,
List<Category> categories,
List<Tag> tags) {
JsonNode root;
try {
root = objectMapper.readTree(stripCodeFence(content));
} catch (JsonProcessingException e) {
throw new BusinessException("AI 返回内容不是有效 JSON,请重试");
} }
return result;
// 兼容模型返回数组或单个对象两种形式
JsonNode node = null;
JsonNode array = root.path("suggestions");
if (array.isArray() && !array.isEmpty()) {
node = array.get(0);
} else if (root.has("categoryId") || root.has("tagIds")) {
node = root;
}
if (node == null) {
return null;
}
Map<Long, Category> categoryMap = categories.stream()
.collect(Collectors.toMap(Category::getId, Function.identity()));
Map<Long, Tag> tagMap = tags.stream()
.collect(Collectors.toMap(Tag::getId, Function.identity()));
AiSuggestionVO vo = new AiSuggestionVO();
vo.setTransactionId(transaction.getId());
// 分类必须存在且与记录类型一致
long categoryId = node.path("categoryId").asLong(0);
Category category = categoryMap.get(categoryId);
if (category != null && category.getType().equals(transaction.getType())) {
vo.setCategoryId(category.getId());
vo.setCategoryName(category.getName());
}
List<Long> tagIds = new ArrayList<>();
List<String> tagNames = new ArrayList<>();
JsonNode tagNode = node.path("tagIds");
if (tagNode.isArray()) {
for (JsonNode item : tagNode) {
Tag tag = tagMap.get(item.asLong(0));
if (tag != null && !tagIds.contains(tag.getId())) {
tagIds.add(tag.getId());
tagNames.add(tag.getName());
}
}
}
vo.setTagIds(tagIds);
vo.setTagNames(tagNames);
vo.setReason(node.path("reason").asText(""));
// 至少存在一项有效建议才返回
if (vo.getCategoryId() == null && tagIds.isEmpty()) {
return null;
}
return vo;
}
/**
* 规范化建议范围,非法值回退为 BOTH。
*
* @param scope 原始范围
* @return CATEGORY / TAG / BOTH
*/
private String normalizeScope(String scope) {
if ("CATEGORY".equalsIgnoreCase(scope)) {
return "CATEGORY";
}
if ("TAG".equalsIgnoreCase(scope)) {
return "TAG";
}
return "BOTH";
}
/**
* 根据建议范围构建系统提示词。
*
* @param scope 建议范围
* @return 系统提示词
*/
private String buildSystemPrompt(String scope) {
if ("CATEGORY".equals(scope)) {
return SYSTEM_PROMPT + "注意:本次只需给出分类建议,tagIds 必须返回空数组。";
}
if ("TAG".equals(scope)) {
return SYSTEM_PROMPT + "注意:本次只需给出标签建议,categoryId 必须返回 null。";
}
return SYSTEM_PROMPT;
}
/**
* 获取并校验当前用户的 AI 配置。
*
* @param userId 用户ID
* @return 有效的 AI 配置
*/
private AiConfig requireConfig(Long userId) {
AiConfig config = aiConfigService.getRawConfig(userId);
if (config == null || !Boolean.TRUE.equals(config.getEnabled())) {
throw new BusinessException("AI 智能建议未启用,请先在 AI 设置中开启");
}
if (isBlank(config.getApiKey())) {
throw new BusinessException("请先配置 DeepSeek API Key");
}
return config;
}
/**
* 按「类型 + 支付方式 + 备注」聚合交易记录,并保持原有顺序。
*
* <p>备注为空的记录不参与聚合,各自独立成组,避免无关记录被归为一类。</p>
*
* @param transactions 待分析记录列表
* @return 分组后的记录列表,每组至少一条
*/
private List<List<Transaction>> groupTransactions(List<Transaction> transactions) {
Map<String, List<Transaction>> grouped = new LinkedHashMap<>();
for (Transaction transaction : transactions) {
String key;
if (isBlank(transaction.getRemark())) {
// 备注为空时以记录ID作为唯一键,保证独立成组
key = "__single__" + transaction.getId();
} else {
key = transaction.getType() + "|"
+ (transaction.getPaymentMethod() == null ? "" : transaction.getPaymentMethod())
+ "|" + transaction.getRemark();
}
grouped.computeIfAbsent(key, k -> new ArrayList<>()).add(transaction);
}
return new ArrayList<>(grouped.values());
}
/**
* 将列表按固定大小切分为多个子列表。
*
* @param list 原始列表
* @param size 每个子列表的最大长度
* @param <T> 元素类型
* @return 切分后的子列表集合
*/
private <T> List<List<T>> partition(List<T> list, int size) {
List<List<T>> parts = new ArrayList<>();
for (int i = 0; i < list.size(); i += size) {
parts.add(list.subList(i, Math.min(i + size, list.size())));
}
return parts;
} }
/** /**
@@ -276,7 +524,11 @@ public class AiSuggestionServiceImpl implements AiSuggestionService {
private List<AiSuggestionVO> parseSuggestions(String content, private List<AiSuggestionVO> parseSuggestions(String content,
List<Transaction> transactions, List<Transaction> transactions,
List<Category> categories, List<Category> categories,
List<Tag> tags) { List<Tag> tags,
String scope) {
// 仅解析用户选择的范围
boolean wantCategory = !"TAG".equals(scope);
boolean wantTag = !"CATEGORY".equals(scope);
JsonNode root; JsonNode root;
try { try {
root = objectMapper.readTree(stripCodeFence(content)); root = objectMapper.readTree(stripCodeFence(content));
@@ -305,24 +557,28 @@ public class AiSuggestionServiceImpl implements AiSuggestionService {
AiSuggestionVO vo = new AiSuggestionVO(); AiSuggestionVO vo = new AiSuggestionVO();
vo.setTransactionId(id); vo.setTransactionId(id);
// 分类必须存在且与记录类型一致 // 分类必须存在且与记录类型一致(仅标签建议时跳过)
long categoryId = node.path("categoryId").asLong(0); if (wantCategory) {
Category category = categoryMap.get(categoryId); long categoryId = node.path("categoryId").asLong(0);
if (category != null && category.getType().equals(transaction.getType())) { Category category = categoryMap.get(categoryId);
vo.setCategoryId(category.getId()); if (category != null && category.getType().equals(transaction.getType())) {
vo.setCategoryName(category.getName()); vo.setCategoryId(category.getId());
vo.setCategoryName(category.getName());
}
} }
// 标签过滤为属于当前用户的标签 // 标签过滤为属于当前用户的标签(仅分类建议时跳过)
List<Long> tagIds = new ArrayList<>(); List<Long> tagIds = new ArrayList<>();
List<String> tagNames = new ArrayList<>(); List<String> tagNames = new ArrayList<>();
JsonNode tagNode = node.path("tagIds"); if (wantTag) {
if (tagNode.isArray()) { JsonNode tagNode = node.path("tagIds");
for (JsonNode item : tagNode) { if (tagNode.isArray()) {
Tag tag = tagMap.get(item.asLong(0)); for (JsonNode item : tagNode) {
if (tag != null && !tagIds.contains(tag.getId())) { Tag tag = tagMap.get(item.asLong(0));
tagIds.add(tag.getId()); if (tag != null && !tagIds.contains(tag.getId())) {
tagNames.add(tag.getName()); tagIds.add(tag.getId());
tagNames.add(tag.getName());
}
} }
} }
} }
@@ -0,0 +1,169 @@
package cn.rosercode.finance.service.impl;
import cn.rosercode.finance.client.DeepSeekClient;
import cn.rosercode.finance.client.DeepSeekResponse;
import cn.rosercode.finance.common.BusinessException;
import cn.rosercode.finance.entity.AiConfig;
import cn.rosercode.finance.enums.AiCallType;
import cn.rosercode.finance.service.AiConfigService;
import cn.rosercode.finance.service.AiTagService;
import cn.rosercode.finance.service.AiUsageService;
import cn.rosercode.finance.vo.AiTagResultVO;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.stereotype.Service;
import java.util.regex.Pattern;
/**
* AI 生成标签服务实现类,根据自然语言描述生成标签名称与颜色。
*
* @date 2026-09-18 09:39:25
*/
@Service
public class AiTagServiceImpl implements AiTagService {
/** 标签名称最大长度 */
private static final int NAME_MAX_LENGTH = 20;
/** 默认颜色,AI 未返回合法颜色时使用 */
private static final String DEFAULT_COLOR = "#2080f0";
/** 十六进制颜色校验 */
private static final Pattern COLOR_PATTERN = Pattern.compile("^#[0-9a-fA-F]{6}$");
/** 系统提示词,要求模型严格返回 JSON */
private static final String SYSTEM_PROMPT = "你是一个个人记账应用的标签助手。"
+ "请根据用户对标签用途的自然语言描述,生成一个简洁的记账标签。"
+ "要求:1. name 为 2-6 个汉字的标签名称;"
+ "2. color 为高饱和、适合作为标签背景色且搭配白色文字清晰可读的十六进制颜色;"
+ "3. 只输出 JSON,不要输出任何多余内容。"
+ "输出格式:{\"name\":\"报销\",\"color\":\"#2f80ed\",\"reason\":\"简要中文理由\"}";
private final AiConfigService aiConfigService;
private final DeepSeekClient deepSeekClient;
private final AiUsageService aiUsageService;
private final ObjectMapper objectMapper;
public AiTagServiceImpl(AiConfigService aiConfigService,
DeepSeekClient deepSeekClient,
AiUsageService aiUsageService,
ObjectMapper objectMapper) {
this.aiConfigService = aiConfigService;
this.deepSeekClient = deepSeekClient;
this.aiUsageService = aiUsageService;
this.objectMapper = objectMapper;
}
/**
* {@inheritDoc}
*
* 将用户描述直接作为提示词内容,解析模型返回的标签名称与颜色。
*/
@Override
public AiTagResultVO parse(Long userId, String text) {
if (text == null || text.isBlank()) {
throw new BusinessException("请输入标签描述");
}
AiConfig config = requireConfig(userId);
long start = System.currentTimeMillis();
DeepSeekResponse aiResponse;
try {
aiResponse = deepSeekClient.chat(config.getBaseUrl(), config.getApiKey(), config.getModel(),
SYSTEM_PROMPT, text.trim());
} catch (RuntimeException e) {
// 失败也记录一次使用,便于排查问题
aiUsageService.recordFailure(userId, AiCallType.TAG, config.getModel(), 0,
System.currentTimeMillis() - start, e.getMessage());
throw e;
}
aiUsageService.recordSuccess(userId, AiCallType.TAG, config.getModel(), 0,
aiResponse, System.currentTimeMillis() - start);
return parseResult(aiResponse.getContent());
}
/**
* 解析模型返回的 JSON 并校验字段合法性。
*
* @param content 模型输出内容
* @return 标签建议
*/
private AiTagResultVO parseResult(String content) {
JsonNode root;
try {
root = objectMapper.readTree(stripCodeFence(content));
} catch (JsonProcessingException e) {
throw new BusinessException("AI 返回内容不是有效 JSON,请重试");
}
String name = root.path("name").asText("").trim();
if (isBlank(name) || name.length() > NAME_MAX_LENGTH) {
throw new BusinessException("AI 未返回有效的标签名称,请重试");
}
// 颜色非法时回退默认色
String color = root.path("color").asText("").trim();
if (!COLOR_PATTERN.matcher(color).matches()) {
color = DEFAULT_COLOR;
}
AiTagResultVO vo = new AiTagResultVO();
vo.setName(name);
vo.setColor(color.toLowerCase());
vo.setReason(root.path("reason").asText(""));
return vo;
}
/**
* 获取并校验当前用户的 AI 配置。
*
* @param userId 用户ID
* @return 有效的 AI 配置
*/
private AiConfig requireConfig(Long userId) {
AiConfig config = aiConfigService.getRawConfig(userId);
if (config == null || !Boolean.TRUE.equals(config.getEnabled())) {
throw new BusinessException("AI 功能未启用,请先在 AI 设置中开启");
}
if (isBlank(config.getApiKey())) {
throw new BusinessException("请先配置 DeepSeek API Key");
}
return config;
}
/**
* 去除模型可能附加的 Markdown 代码块围栏。
*
* @param content 原始内容
* @return 清理后的内容
*/
private String stripCodeFence(String content) {
if (content == null) {
return "";
}
String cleaned = content.trim();
if (cleaned.startsWith("```")) {
int firstLineEnd = cleaned.indexOf('\n');
if (firstLineEnd > 0) {
cleaned = cleaned.substring(firstLineEnd + 1);
}
if (cleaned.endsWith("```")) {
cleaned = cleaned.substring(0, cleaned.length() - 3);
}
}
return cleaned.trim();
}
/**
* 判断字符串是否为空白。
*
* @param value 待判断字符串
* @return 为 null 或全空白时返回 true
*/
private boolean isBlank(String value) {
return value == null || value.isBlank();
}
}
@@ -118,7 +118,7 @@ public class BillImportServiceImpl implements BillImportService {
: requireCategory(userId, request.getIncomeCategoryId(), "INCOME"); : requireCategory(userId, request.getIncomeCategoryId(), "INCOME");
// 默认标签:仅保留属于当前用户的标签 // 默认标签:仅保留属于当前用户的标签
List<Long> tagIds = request.getTagIds() == null ? List.of() List<Long> tagIds = request.getTagIds() == null || request.getTagIds().isEmpty() ? List.of()
: tagMapper.selectBatchIds(request.getTagIds()).stream() : tagMapper.selectBatchIds(request.getTagIds()).stream()
.filter(tag -> tag.getUserId().equals(userId)) .filter(tag -> tag.getUserId().equals(userId))
.map(Tag::getId) .map(Tag::getId)
@@ -113,10 +113,22 @@ public class TransactionServiceImpl implements TransactionService {
if (StringUtils.hasText(query.getRemark())) { if (StringUtils.hasText(query.getRemark())) {
wrapper.like(Transaction::getRemark, query.getRemark()); wrapper.like(Transaction::getRemark, query.getRemark());
} }
// 按标签筛选:命中任一选中标签的记录 // 按标签筛选:命中任一选中标签,或筛选无任何标签的记录
if (query.getTagIds() != null && !query.getTagIds().isEmpty()) { boolean hasTagIds = query.getTagIds() != null && !query.getTagIds().isEmpty();
wrapper.inSql(Transaction::getId, "SELECT transaction_id FROM t_transaction_tag WHERE tag_id IN (" boolean untagged = Boolean.TRUE.equals(query.getUntagged());
+ query.getTagIds().stream().map(String::valueOf).collect(Collectors.joining(",")) + ")"); if (hasTagIds || untagged) {
wrapper.and(w -> {
if (hasTagIds) {
w.inSql(Transaction::getId, "SELECT transaction_id FROM t_transaction_tag WHERE tag_id IN ("
+ query.getTagIds().stream().map(String::valueOf).collect(Collectors.joining(",")) + ")");
}
if (untagged) {
if (hasTagIds) {
w.or();
}
w.apply("NOT EXISTS (SELECT 1 FROM t_transaction_tag tt WHERE tt.transaction_id = t_transaction.id)");
}
});
} }
// 应用排序:默认按交易时间降序,支持按交易时间/金额排序 // 应用排序:默认按交易时间降序,支持按交易时间/金额排序
@@ -190,10 +202,12 @@ public class TransactionServiceImpl implements TransactionService {
throw new BusinessException("记录不存在"); throw new BusinessException("记录不存在");
} }
// 校验分类是否存在且属于当前用户 // 分类可选:仅在指定分类时校验其归属,未指定则保持未分类
Category category = categoryMapper.selectById(request.getCategoryId()); if (request.getCategoryId() != null) {
if (category == null || !category.getUserId().equals(userId)) { Category category = categoryMapper.selectById(request.getCategoryId());
throw new BusinessException("分类不存在"); if (category == null || !category.getUserId().equals(userId)) {
throw new BusinessException("分类不存在");
}
} }
transaction.setCategoryId(request.getCategoryId()); transaction.setCategoryId(request.getCategoryId());
@@ -587,10 +601,22 @@ public class TransactionServiceImpl implements TransactionService {
if (StringUtils.hasText(query.getRemark())) { if (StringUtils.hasText(query.getRemark())) {
wrapper.like(Transaction::getRemark, query.getRemark()); wrapper.like(Transaction::getRemark, query.getRemark());
} }
// 按标签筛选:命中任一选中标签的记录 // 按标签筛选:命中任一选中标签,或筛选无任何标签的记录
if (query.getTagIds() != null && !query.getTagIds().isEmpty()) { boolean hasTagIds = query.getTagIds() != null && !query.getTagIds().isEmpty();
wrapper.inSql(Transaction::getId, "SELECT transaction_id FROM t_transaction_tag WHERE tag_id IN (" boolean untagged = Boolean.TRUE.equals(query.getUntagged());
+ query.getTagIds().stream().map(String::valueOf).collect(Collectors.joining(",")) + ")"); if (hasTagIds || untagged) {
wrapper.and(w -> {
if (hasTagIds) {
w.inSql(Transaction::getId, "SELECT transaction_id FROM t_transaction_tag WHERE tag_id IN ("
+ query.getTagIds().stream().map(String::valueOf).collect(Collectors.joining(",")) + ")");
}
if (untagged) {
if (hasTagIds) {
w.or();
}
w.apply("NOT EXISTS (SELECT 1 FROM t_transaction_tag tt WHERE tt.transaction_id = t_transaction.id)");
}
});
} }
// 应用排序:默认按交易时间降序,支持按交易时间/金额排序 // 应用排序:默认按交易时间降序,支持按交易时间/金额排序
@@ -0,0 +1,24 @@
package cn.rosercode.finance.vo;
import lombok.Data;
/**
* AI 生成分类结果视图对象,字段与分类表单对应。
*
* @date 2026-09-18 09:29:33
*/
@Data
public class AiCategoryResultVO {
/** 建议的分类名称 */
private String name;
/** 建议的收支类型:INCOME / EXPENSE */
private String type;
/** 建议的分类图标(Emoji) */
private String icon;
/** 生成理由,用于向用户说明 */
private String reason;
}
@@ -12,9 +12,15 @@ import java.util.List;
@Data @Data
public class AiSuggestionVO { public class AiSuggestionVO {
/** 交易记录ID */ /** 代表交易记录ID */
private Long transactionId; private Long transactionId;
/** 该建议适用的全部记录ID(按类型、备注、支付方式聚合后的一组记录) */
private List<Long> transactionIds;
/** 命中的记录数,等于 transactionIds 的大小 */
private Integer count;
/** 建议分类ID,为 null 表示无分类建议 */ /** 建议分类ID,为 null 表示无分类建议 */
private Long categoryId; private Long categoryId;
@@ -0,0 +1,21 @@
package cn.rosercode.finance.vo;
import lombok.Data;
/**
* AI 生成标签结果视图对象,字段与标签表单对应。
*
* @date 2026-09-18 09:39:25
*/
@Data
public class AiTagResultVO {
/** 建议的标签名称 */
private String name;
/** 建议的标签颜色(十六进制) */
private String color;
/** 生成理由,用于向用户说明 */
private String reason;
}