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