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
|
||||
}
|
||||
|
||||
/** 单条 AI 建议 */
|
||||
/** 单条 AI 建议(按类型、备注、支付方式聚合后的一组记录) */
|
||||
export interface AiSuggestion {
|
||||
transactionId: number
|
||||
transactionIds: number[]
|
||||
count: number
|
||||
categoryId: number | null
|
||||
categoryName: string | null
|
||||
tagIds: number[]
|
||||
@@ -35,9 +37,50 @@ export function testAiConfig() {
|
||||
return request.post('/ai/config/test', {}, { timeout: 60000 })
|
||||
}
|
||||
|
||||
/** 生成分类与标签建议(调用大模型,超时放宽) */
|
||||
export function suggestByAi(ids: number[]) {
|
||||
return request.post('/ai/suggest', { ids }, { timeout: 120000 })
|
||||
/** 生成分类与标签建议(scope:BOTH/CATEGORY/TAG;重复记录聚合后按批调用,超时按批数放宽) */
|
||||
export function suggestByAi(ids: number[], scope = 'BOTH') {
|
||||
const batches = Math.max(1, Math.ceil(ids.length / 100))
|
||||
return request.post('/ai/suggest', { ids, scope }, { timeout: Math.min(Math.max(120000, batches * 90000), 600000) })
|
||||
}
|
||||
|
||||
/** AI 生成的分类建议 */
|
||||
export interface AiCategoryResult {
|
||||
name: string
|
||||
type: string
|
||||
icon: string
|
||||
reason: string
|
||||
}
|
||||
|
||||
/** 根据自然语言描述生成分类(调用大模型,超时放宽) */
|
||||
export function parseCategoryByAi(text: string) {
|
||||
return request.post('/ai/category', { text }, { timeout: 60000 })
|
||||
}
|
||||
|
||||
/** AI 生成的标签建议 */
|
||||
export interface AiTagResult {
|
||||
name: string
|
||||
color: string
|
||||
reason: string
|
||||
}
|
||||
|
||||
/** 根据自然语言描述生成标签(调用大模型,超时放宽) */
|
||||
export function parseTagByAi(text: string) {
|
||||
return request.post('/ai/tag', { text }, { timeout: 60000 })
|
||||
}
|
||||
|
||||
/** 根据表单当前内容生成单条分类与标签建议(调用大模型,超时放宽) */
|
||||
export function suggestOneByAi(data: { type: string; amount?: number | null; paymentMethod?: string; remark?: string }) {
|
||||
return request.post('/ai/suggest-one', data, { timeout: 60000 })
|
||||
}
|
||||
|
||||
/** AI 推荐分类 Emoji 图标(调用大模型,超时放宽) */
|
||||
export function suggestEmoji(name: string, type?: string) {
|
||||
return request.post('/ai/emoji', { name, type }, { timeout: 60000 })
|
||||
}
|
||||
|
||||
/** AI 推荐标签颜色(调用大模型,超时放宽) */
|
||||
export function suggestColor(name: string) {
|
||||
return request.post('/ai/color', { name }, { timeout: 60000 })
|
||||
}
|
||||
|
||||
/** 一键应用建议:覆盖分类并追加标签 */
|
||||
|
||||
@@ -38,9 +38,9 @@ export function createTransaction(data: {
|
||||
return request.post('/transaction', data)
|
||||
}
|
||||
|
||||
/** 更新指定交易记录 */
|
||||
/** 更新指定交易记录(categoryId 为空表示不设置分类) */
|
||||
export function updateTransaction(id: number, data: {
|
||||
categoryId: number
|
||||
categoryId: number | null
|
||||
type: string
|
||||
amount: number
|
||||
paymentMethod?: string
|
||||
|
||||
@@ -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' },
|
||||
pageBalance: { zh: '本页结余', en: 'Page Balance' },
|
||||
column: {
|
||||
index: { zh: '序号', en: 'No.' },
|
||||
transactionTime: { zh: '交易时间', en: 'Time' },
|
||||
type: { zh: '类型', en: 'Type' },
|
||||
category: { zh: '分类', en: 'Category' },
|
||||
@@ -162,6 +163,7 @@ export default {
|
||||
},
|
||||
notIncluded: { zh: '不计入', en: 'Excluded' },
|
||||
uncategorized: { zh: '未分类', en: 'Uncategorized' },
|
||||
untagged: { zh: '无标签', en: 'Untagged' },
|
||||
remarkHidden: { zh: '备注已隐藏', en: 'Remark hidden' },
|
||||
remarkView: { zh: '查看', en: 'View' },
|
||||
remarkHide: { zh: '隐藏', en: 'Hide' },
|
||||
@@ -228,7 +230,9 @@ export default {
|
||||
namePlaceholder: { zh: '请输入分类名称', en: 'Enter category name' },
|
||||
type: { zh: '类型', en: 'Type' },
|
||||
icon: { zh: '图标', en: 'Icon' },
|
||||
iconPlaceholder: { zh: '可选,如 emoji', en: 'Optional, e.g. emoji' },
|
||||
iconPlaceholder: { zh: '点击选择 Emoji', en: 'Click to pick an emoji' },
|
||||
emojiInputPlaceholder: { zh: '自定义输入 Emoji 或文字', en: 'Custom emoji or text' },
|
||||
clearIcon: { zh: '清除图标', en: 'Clear icon' },
|
||||
sort: { zh: '排序', en: 'Order' },
|
||||
nameRequired: { zh: '请输入分类名称', en: 'Please enter a category name' },
|
||||
deleteConfirm: { zh: '确定要删除这个分类吗?', en: 'Are you sure you want to delete this category?' },
|
||||
@@ -326,15 +330,57 @@ export default {
|
||||
testSuccess: { zh: '连接成功', en: 'Connection succeeded' },
|
||||
testFailed: { zh: '连接失败', en: 'Connection failed' },
|
||||
suggestButton: { zh: 'AI 智能建议', en: 'AI suggestions' },
|
||||
pickButton: { zh: 'AI 推荐', en: 'AI Pick' },
|
||||
scope: {
|
||||
label: { zh: '建议内容', en: 'Scope' },
|
||||
both: { zh: '分类与标签', en: 'Category & Tags' },
|
||||
category: { zh: '仅分类', en: 'Category only' },
|
||||
tag: { zh: '仅标签', en: 'Tags only' },
|
||||
},
|
||||
suggestTitle: { zh: 'AI 分类与标签建议', en: 'AI category & tag suggestions' },
|
||||
suggesting: { zh: '正在分析,请稍候…', en: 'Analyzing, please wait…' },
|
||||
suggestFailed: { zh: 'AI 建议生成失败', en: 'Failed to generate AI suggestions' },
|
||||
noSuggestion: { zh: 'AI 未返回有效建议', en: 'AI returned no valid suggestions' },
|
||||
applyAll: { zh: '全部应用({count})', en: 'Apply all ({count})' },
|
||||
applyAll: { zh: '应用勾选({count})', en: 'Apply selected ({count})' },
|
||||
applyNone: { zh: '请至少勾选一条建议', en: 'Please select at least one suggestion' },
|
||||
aggregated: { zh: '已聚合为 {groups} 类、共 {count} 条记录', en: 'Aggregated into {groups} groups, {count} records in total' },
|
||||
applySuccess: { zh: '已应用 {count} 条建议', en: 'Applied {count} suggestions' },
|
||||
oneApplied: { zh: '已应用 AI 推荐分类', en: 'AI category applied' },
|
||||
tagApplied: { zh: '已应用 AI 推荐标签', en: 'AI tags applied' },
|
||||
applyFailed: { zh: '应用建议失败', en: 'Failed to apply suggestions' },
|
||||
applyHint: { zh: '将覆盖分类并追加标签,确认后立即写入', en: 'Categories will be overwritten and tags appended; changes are written immediately.' },
|
||||
needConfig: { zh: 'AI 未启用,请先在「AI 设置」中配置', en: 'AI is not enabled. Please configure it in AI Settings first.' },
|
||||
decorate: {
|
||||
emojiButton: { zh: 'AI 推荐', en: 'AI Pick' },
|
||||
colorButton: { zh: 'AI 推荐', en: 'AI Pick' },
|
||||
nameRequired: { zh: '请先输入名称', en: 'Please enter a name first' },
|
||||
failed: { zh: 'AI 推荐失败', en: 'AI recommendation failed' },
|
||||
},
|
||||
category: {
|
||||
label: { zh: 'AI 生成分类', en: 'AI Category' },
|
||||
placeholder: { zh: '描述用途,如:日常通勤打车地铁', en: 'Describe it, e.g. daily commute by taxi and metro' },
|
||||
button: { zh: 'AI 生成', en: 'Generate' },
|
||||
textRequired: { zh: '请输入分类描述', en: 'Please enter a description' },
|
||||
applied: { zh: '已生成分类,请确认后保存', en: 'Category generated, please confirm and save' },
|
||||
failed: { zh: 'AI 生成分类失败', en: 'Failed to generate category' },
|
||||
},
|
||||
newCategory: {
|
||||
button: { zh: 'AI 新建', en: 'AI New' },
|
||||
hint: { zh: 'AI 建议新分类:', en: 'AI suggests:' },
|
||||
save: { zh: '保存并选用', en: 'Save & Select' },
|
||||
saved: { zh: '已创建并选用新分类', en: 'Category created and selected' },
|
||||
exists: { zh: '已存在同名分类,已为你选中', en: 'A same-name category exists and has been selected' },
|
||||
needInfo: { zh: '请先填写备注或金额,便于生成分类', en: 'Please fill in a remark or amount first' },
|
||||
},
|
||||
newTag: {
|
||||
button: { zh: 'AI 新建', en: 'AI New' },
|
||||
hint: { zh: 'AI 建议新标签:', en: 'AI suggests tag:' },
|
||||
save: { zh: '保存并选用', en: 'Save & Select' },
|
||||
saved: { zh: '已创建并选用新标签', en: 'Tag created and selected' },
|
||||
exists: { zh: '已存在同名标签,已为你选中', en: 'A same-name tag exists and has been selected' },
|
||||
needInfo: { zh: '请先填写备注或金额,便于生成标签', en: 'Please fill in a remark or amount first' },
|
||||
failed: { zh: 'AI 生成标签失败', en: 'Failed to generate tag' },
|
||||
},
|
||||
query: {
|
||||
placeholder: { zh: '用自然语言查询,如:上个月餐饮花了多少', en: 'Ask in natural language, e.g. dining expenses last month' },
|
||||
examples: { zh: '上个月餐饮|超过500的支出|待报销的支出|微信支付', en: 'Dining last month|Expenses over 500|Pending reimbursement|Paid by WeChat' },
|
||||
@@ -369,6 +415,7 @@ export default {
|
||||
},
|
||||
column: {
|
||||
record: { zh: '记录', en: 'Record' },
|
||||
count: { zh: '命中条数', en: 'Matched' },
|
||||
category: { zh: '建议分类', en: 'Suggested category' },
|
||||
tags: { zh: '建议标签', en: 'Suggested tags' },
|
||||
reason: { zh: '理由', en: 'Reason' },
|
||||
@@ -390,6 +437,8 @@ export default {
|
||||
empty: { zh: '暂无调用记录', en: 'No usage records yet' },
|
||||
type: {
|
||||
suggest: { zh: '生成建议', en: 'Suggest' },
|
||||
decorate: { zh: '图标配色', en: 'Icon & Color' },
|
||||
category: { zh: '生成分类', en: 'Category' },
|
||||
test: { zh: '测试连接', en: 'Test' },
|
||||
},
|
||||
column: {
|
||||
|
||||
@@ -331,7 +331,7 @@ async function handleConfirm() {
|
||||
billType: billType.value,
|
||||
expenseCategoryId: expenseCategoryId.value,
|
||||
incomeCategoryId: incomeCategoryId.value,
|
||||
tagIds: importTagIds.value,
|
||||
tagIds: importTagIds.value.length ? importTagIds.value : undefined,
|
||||
items,
|
||||
})
|
||||
result.value = res.data
|
||||
|
||||
@@ -5,6 +5,8 @@ import { useI18n } from 'vue-i18n'
|
||||
import { useMessage, useDialog, NButton, NSpace, NDataTable, NModal, NForm, NFormItem, NInput, NInputNumber, NSelect } from 'naive-ui'
|
||||
import type { DataTableColumns } from 'naive-ui'
|
||||
import { getCategoryList, createCategory, updateCategory, deleteCategory } from '../api/category'
|
||||
import { getAiConfig, suggestEmoji, parseCategoryByAi } from '../api/ai'
|
||||
import EmojiPicker from '../components/EmojiPicker.vue'
|
||||
|
||||
const message = useMessage() // 消息提示
|
||||
const dialog = useDialog() // 对话框
|
||||
@@ -26,6 +28,10 @@ const loading = ref(false) // 列表加载状态
|
||||
const showModal = ref(false) // 新增/编辑弹窗显示状态
|
||||
const editingId = ref<number | null>(null) // 当前编辑的分类 ID(null 表示新增)
|
||||
const form = ref({ name: '', type: 'EXPENSE', icon: '', sortOrder: 0 }) // 表单数据
|
||||
const aiEnabled = ref(false) // AI 是否启用,用于控制推荐入口显示
|
||||
const aiLoading = ref(false) // AI 推荐请求状态
|
||||
const aiText = ref('') // AI 生成分类的自然语言描述
|
||||
const aiGenLoading = ref(false) // AI 生成分类请求状态
|
||||
|
||||
// 类型选项
|
||||
const typeOptions = computed(() => [
|
||||
@@ -40,8 +46,24 @@ const expenseList = computed(() => categories.value.filter(c => c.type === 'EXPE
|
||||
/** 创建表格列配置 */
|
||||
function createColumns(): DataTableColumns<CategoryItem> {
|
||||
return [
|
||||
{ title: t('common.name'), key: 'name' },
|
||||
{ title: t('category.icon'), key: 'icon' },
|
||||
{
|
||||
title: t('common.name'), key: 'name',
|
||||
render(row) {
|
||||
// 名称前展示 Emoji 图标,未设置时占位
|
||||
return h('span', { style: { display: 'flex', alignItems: 'center', gap: '6px' } }, [
|
||||
h('span', { style: { fontSize: '18px', lineHeight: '1' } }, row.icon || '📁'),
|
||||
h('span', null, row.name),
|
||||
])
|
||||
},
|
||||
},
|
||||
{
|
||||
title: t('category.icon'), key: 'icon', width: 100,
|
||||
render(row) {
|
||||
return row.icon
|
||||
? h('span', { style: { fontSize: '20px', lineHeight: '1' } }, row.icon)
|
||||
: h('span', { style: { color: '#ccc' } }, '—')
|
||||
},
|
||||
},
|
||||
{ title: t('common.sort'), key: 'sortOrder', width: 80 },
|
||||
{
|
||||
title: t('common.operation'), key: 'actions', width: 180,
|
||||
@@ -79,6 +101,7 @@ async function fetchData() {
|
||||
function handleAdd() {
|
||||
editingId.value = null
|
||||
form.value = { name: '', type: 'EXPENSE', icon: '', sortOrder: 0 }
|
||||
aiText.value = ''
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
@@ -86,6 +109,7 @@ function handleAdd() {
|
||||
function handleEdit(row: CategoryItem) {
|
||||
editingId.value = row.id
|
||||
form.value = { name: row.name, type: row.type, icon: row.icon || '', sortOrder: row.sortOrder }
|
||||
aiText.value = ''
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
@@ -131,8 +155,64 @@ function handleDelete(id: number) {
|
||||
})
|
||||
}
|
||||
|
||||
// 组件挂载时加载分类数据
|
||||
onMounted(fetchData)
|
||||
/** 加载 AI 启用状态,用于控制推荐入口显示 */
|
||||
async function loadAiConfig() {
|
||||
try {
|
||||
const res: any = await getAiConfig()
|
||||
aiEnabled.value = !!res.data.enabled
|
||||
} catch (e) {
|
||||
// 静默处理,推荐入口保持隐藏
|
||||
aiEnabled.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** AI 推荐分类 Emoji 图标 */
|
||||
async function handleAiEmoji() {
|
||||
if (!form.value.name) {
|
||||
message.warning(t('ai.decorate.nameRequired'))
|
||||
return
|
||||
}
|
||||
aiLoading.value = true
|
||||
try {
|
||||
const res: any = await suggestEmoji(form.value.name, form.value.type)
|
||||
form.value.icon = res.data
|
||||
} catch (e: any) {
|
||||
message.error(e.message || t('ai.decorate.failed'))
|
||||
} finally {
|
||||
aiLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** AI 根据自然语言描述生成分类,并填充名称、类型与图标 */
|
||||
async function handleAiGenerate() {
|
||||
if (!aiText.value.trim()) {
|
||||
message.warning(t('ai.category.textRequired'))
|
||||
return
|
||||
}
|
||||
aiGenLoading.value = true
|
||||
try {
|
||||
const res: any = await parseCategoryByAi(aiText.value.trim())
|
||||
const data = res.data
|
||||
if (!data || !data.name) {
|
||||
message.warning(t('ai.category.failed'))
|
||||
return
|
||||
}
|
||||
form.value.name = data.name
|
||||
if (data.type) form.value.type = data.type
|
||||
if (data.icon) form.value.icon = data.icon
|
||||
message.success(t('ai.category.applied'))
|
||||
} catch (e: any) {
|
||||
message.error(e.message || t('ai.category.failed'))
|
||||
} finally {
|
||||
aiGenLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// 组件挂载时加载分类数据与 AI 配置
|
||||
onMounted(() => {
|
||||
fetchData()
|
||||
loadAiConfig()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -154,14 +234,28 @@ onMounted(fetchData)
|
||||
<!-- 新增/编辑弹窗 -->
|
||||
<NModal v-model:show="showModal" preset="card" :title="editingId ? t('category.editTitle') : t('category.addTitle')" style="width: 450px;">
|
||||
<NForm>
|
||||
<NFormItem v-if="aiEnabled" :label="t('ai.category.label')">
|
||||
<div style="display: flex; gap: 8px; width: 100%;">
|
||||
<NInput v-model:value="aiText" :placeholder="t('ai.category.placeholder')" @keyup.enter="handleAiGenerate" />
|
||||
<NButton :loading="aiGenLoading" @click="handleAiGenerate">{{ t('ai.category.button') }}</NButton>
|
||||
</div>
|
||||
</NFormItem>
|
||||
<NFormItem :label="t('category.name')">
|
||||
<NInput v-model:value="form.name" :placeholder="t('category.namePlaceholder')" />
|
||||
<div style="display: flex; gap: 8px; width: 100%;">
|
||||
<NInput v-model:value="form.name" :placeholder="t('category.namePlaceholder')" />
|
||||
<NButton v-if="aiEnabled" :loading="aiLoading" @click="handleAiEmoji">{{ t('ai.decorate.emojiButton') }}</NButton>
|
||||
</div>
|
||||
</NFormItem>
|
||||
<NFormItem :label="t('category.type')">
|
||||
<NSelect v-model:value="form.type" :options="typeOptions" />
|
||||
</NFormItem>
|
||||
<NFormItem :label="t('category.icon')">
|
||||
<NInput v-model:value="form.icon" :placeholder="t('category.iconPlaceholder')" />
|
||||
<EmojiPicker
|
||||
v-model="form.icon"
|
||||
:placeholder="t('category.iconPlaceholder')"
|
||||
:input-placeholder="t('category.emojiInputPlaceholder')"
|
||||
:clear-text="t('category.clearIcon')"
|
||||
/>
|
||||
</NFormItem>
|
||||
<NFormItem :label="t('category.sort')">
|
||||
<NInputNumber v-model:value="form.sortOrder" :min="0" />
|
||||
|
||||
@@ -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 type { DataTableColumns } from 'naive-ui'
|
||||
import { getTagList, createTag, updateTag, deleteTag } from '../api/tag'
|
||||
import { getAiConfig, suggestColor } from '../api/ai'
|
||||
|
||||
const message = useMessage() // 消息提示
|
||||
const dialog = useDialog() // 对话框
|
||||
@@ -25,6 +26,14 @@ const loading = ref(false) // 列表加载状态
|
||||
const showModal = ref(false) // 新增/编辑弹窗显示状态
|
||||
const editingId = ref<number | null>(null) // 当前编辑的标签 ID(null 表示新增)
|
||||
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> {
|
||||
@@ -35,7 +44,22 @@ function createColumns(): DataTableColumns<TagItem> {
|
||||
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.operation'), key: 'actions', width: 180,
|
||||
@@ -120,8 +144,39 @@ function handleDelete(id: number) {
|
||||
})
|
||||
}
|
||||
|
||||
// 组件挂载时加载标签数据
|
||||
onMounted(fetchData)
|
||||
/** 加载 AI 启用状态,用于控制推荐入口显示 */
|
||||
async function loadAiConfig() {
|
||||
try {
|
||||
const res: any = await getAiConfig()
|
||||
aiEnabled.value = !!res.data.enabled
|
||||
} catch (e) {
|
||||
// 静默处理,推荐入口保持隐藏
|
||||
aiEnabled.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/** AI 推荐标签颜色 */
|
||||
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>
|
||||
|
||||
<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;">
|
||||
<NForm>
|
||||
<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 :label="t('tag.color')">
|
||||
<NColorPicker v-model:value="form.color" />
|
||||
<NColorPicker v-model:value="form.color" :swatches="colorSwatches" :show-alpha="false" />
|
||||
</NFormItem>
|
||||
<NFormItem :label="t('tag.sort')">
|
||||
<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 type { DataTableColumns, AutoCompleteOption } from 'naive-ui'
|
||||
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 { getCategoryList } from '../api/category'
|
||||
import { getTagList } from '../api/tag'
|
||||
import { getCategoryList, createCategory } from '../api/category'
|
||||
import { getTagList, createTag } from '../api/tag'
|
||||
import BillImportModal from './BillImportModal.vue'
|
||||
|
||||
const message = useMessage() // 消息提示
|
||||
@@ -83,8 +83,47 @@ const showBatchModal = ref(false) // 批量设置分类弹窗显示状态
|
||||
const showAiModal = ref(false) // AI 建议弹窗显示状态
|
||||
const aiEnabled = 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 aiSuggestions = ref<AiSuggestion[]>([]) // AI 建议列表
|
||||
const checkedAiRowKeys = ref<number[]>([]) // AI 建议弹窗中已勾选的代表记录ID
|
||||
const aiQueryText = ref('') // AI 自然语言查询输入
|
||||
const aiQuerying = ref(false) // AI 查询解析状态
|
||||
const showAiQueryModal = ref(false) // AI 查询结果弹窗显示状态
|
||||
@@ -106,6 +145,9 @@ const batchIncludeInStats = ref<'null' | 'true' | 'false'>('null') // 批量设
|
||||
const batchAddTagIds = ref<number[]>([]) // 批量添加的标签
|
||||
const batchRemoveTagIds = ref<number[]>([]) // 批量移除的标签
|
||||
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(() => [
|
||||
@@ -190,6 +232,21 @@ const categoryFilterOptions = computed(() => [
|
||||
// 标签筛选项
|
||||
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与未分类标记,供查询与导出复用 */
|
||||
function buildCategoryFilter() {
|
||||
const ids = filterCategory.value.filter((v) => v !== 0)
|
||||
@@ -427,6 +484,13 @@ async function setRemarkHidden(row: TransactionItem, hidden: boolean) {
|
||||
/** 创建表格列配置 */
|
||||
function createColumns(): DataTableColumns<TransactionItem> {
|
||||
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,
|
||||
render(row) {
|
||||
@@ -562,7 +626,7 @@ async function fetchData() {
|
||||
remark: filterRemark.value.trim() || undefined,
|
||||
sortField: sortField.value,
|
||||
sortOrder: sortOrder.value,
|
||||
tagIds: filterTag.value.length > 0 ? filterTag.value : undefined,
|
||||
...buildTagFilter(),
|
||||
})
|
||||
transactions.value = res.data.records
|
||||
total.value = res.data.total
|
||||
@@ -598,6 +662,8 @@ function handleViewRaw(row: TransactionItem) {
|
||||
function handleAdd() {
|
||||
editingId.value = null
|
||||
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
|
||||
}
|
||||
|
||||
@@ -614,12 +680,15 @@ function handleEdit(row: TransactionItem) {
|
||||
includeInStats: row.includeInStats ?? true,
|
||||
tagIds: (row.tags || []).map((t) => t.id),
|
||||
}
|
||||
pendingCategory.value = null
|
||||
pendingTag.value = null
|
||||
showModal.value = true
|
||||
}
|
||||
|
||||
/** 提交表单:校验 -> 新增或更新 -> 刷新列表 */
|
||||
async function handleSubmit() {
|
||||
if (!form.value.categoryId) {
|
||||
// 编辑已有记录时允许不设置分类,便于只修改标签等信息
|
||||
if (!form.value.categoryId && !editingId.value) {
|
||||
message.warning(t('transaction.selectCategory'))
|
||||
return
|
||||
}
|
||||
@@ -736,12 +805,15 @@ async function handleAiSuggest() {
|
||||
aiLoading.value = true
|
||||
try {
|
||||
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 || []
|
||||
if (aiSuggestions.value.length === 0) {
|
||||
message.warning(t('ai.noSuggestion'))
|
||||
return
|
||||
}
|
||||
// 默认全选,用户可取消不需要的建议
|
||||
checkedAiRowKeys.value = aiSuggestions.value.map((s) => s.transactionId)
|
||||
message.success(t('ai.aggregated', { groups: aiSuggestions.value.length, count: aiTotalCount.value }))
|
||||
showBatchModal.value = false
|
||||
showAiModal.value = true
|
||||
} 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 建议:覆盖分类并追加标签 */
|
||||
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
|
||||
}
|
||||
aiApplying.value = true
|
||||
try {
|
||||
const payload = aiSuggestions.value.map((s) => ({
|
||||
transactionId: s.transactionId,
|
||||
categoryId: s.categoryId,
|
||||
tagIds: s.tagIds || [],
|
||||
}))
|
||||
// 仅提交勾选的建议,聚合建议按组展开为逐条记录
|
||||
const payload = chosen.flatMap((s) => {
|
||||
const ids = s.transactionIds?.length ? s.transactionIds : [s.transactionId]
|
||||
return ids.map((id) => ({ transactionId: id, categoryId: s.categoryId, tagIds: s.tagIds || [] }))
|
||||
})
|
||||
const res: any = await applyAiSuggestions(payload)
|
||||
message.success(t('ai.applySuccess', { count: res.data }))
|
||||
showAiModal.value = false
|
||||
@@ -1042,7 +1293,9 @@ const activeFilterTags = computed(() => {
|
||||
})
|
||||
|
||||
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({
|
||||
key: `tag:${id}`,
|
||||
label: name,
|
||||
@@ -1075,7 +1328,7 @@ function applyTagClear(tag: { clear: () => void }) {
|
||||
handleSearch()
|
||||
}
|
||||
|
||||
/** AI 建议行:合并交易记录信息用于展示 */
|
||||
/** AI 建议行:合并代表交易记录信息用于展示 */
|
||||
const aiRows = computed(() =>
|
||||
aiSuggestions.value.map((s) => ({
|
||||
...s,
|
||||
@@ -1083,28 +1336,46 @@ const aiRows = computed(() =>
|
||||
})),
|
||||
)
|
||||
|
||||
/** AI 建议表格列定义(随语言切换响应式更新) */
|
||||
const aiColumns = computed<DataTableColumns<any>>(() => [
|
||||
{
|
||||
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 || '-'),
|
||||
])
|
||||
/** 已勾选 AI 建议覆盖的记录总数(聚合组按组内记录数累加) */
|
||||
const aiTotalCount = computed(() => {
|
||||
const selected = new Set(checkedAiRowKeys.value)
|
||||
return aiSuggestions.value
|
||||
.filter((s) => selected.has(s.transactionId))
|
||||
.reduce((sum, s) => sum + (s.transactionIds?.length || 1), 0)
|
||||
})
|
||||
|
||||
/** AI 建议表格列定义(随语言切换与建议范围响应式更新) */
|
||||
const aiColumns = computed<DataTableColumns<any>>(() => {
|
||||
const columns: DataTableColumns<any> = [
|
||||
{ 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.category'), key: 'category', width: 130, render: (row) => row.categoryName || '-' },
|
||||
{
|
||||
title: t('ai.column.tags'), key: 'tags', minWidth: 150,
|
||||
render: (row) => (row.tagNames && row.tagNames.length > 0 ? row.tagNames.join('、') : '-'),
|
||||
},
|
||||
{ title: t('ai.column.reason'), key: 'reason', minWidth: 180, ellipsis: { tooltip: true } },
|
||||
])
|
||||
{ title: t('ai.column.count'), key: 'count', width: 90, render: (row) => row.count ?? 1 },
|
||||
]
|
||||
// 按建议范围展示对应列
|
||||
if (aiScope.value !== 'TAG') {
|
||||
columns.push({ title: t('ai.column.category'), key: 'category', width: 130, render: (row) => row.categoryName || '-' })
|
||||
}
|
||||
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() {
|
||||
@@ -1171,7 +1442,7 @@ async function handleExport() {
|
||||
remark: filterRemark.value.trim() || undefined,
|
||||
sortField: sortField.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 url = window.URL.createObjectURL(blob)
|
||||
@@ -1325,7 +1596,7 @@ onMounted(() => {
|
||||
<div class="filter-label">{{ t('transaction.column.tags') }}</div>
|
||||
<NSelect
|
||||
v-model:value="filterTag"
|
||||
:options="tagFilterOptions"
|
||||
:options="tagFilterSelectOptions"
|
||||
multiple
|
||||
clearable
|
||||
filterable
|
||||
@@ -1429,8 +1700,19 @@ onMounted(() => {
|
||||
<NSelect v-model:value="form.type" :options="typeOptions" />
|
||||
</NFormItem>
|
||||
<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>
|
||||
<!-- 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')">
|
||||
<NInputNumber v-model:value="form.amount" :min="0.01" :precision="2" style="width: 100%;" :placeholder="t('transaction.amountPlaceholder')" />
|
||||
</NFormItem>
|
||||
@@ -1444,15 +1726,27 @@ onMounted(() => {
|
||||
<NInput v-model:value="form.remark" type="textarea" :placeholder="t('transaction.remarkPlaceholder')" />
|
||||
</NFormItem>
|
||||
<NFormItem :label="t('transaction.column.tags')">
|
||||
<NSelect
|
||||
v-model:value="form.tagIds"
|
||||
:options="tagFilterOptions"
|
||||
multiple
|
||||
clearable
|
||||
filterable
|
||||
:placeholder="t('transaction.tagPlaceholder')"
|
||||
/>
|
||||
<div style="display: flex; gap: 8px; width: 100%;">
|
||||
<NSelect
|
||||
v-model:value="form.tagIds"
|
||||
:options="tagFilterOptions"
|
||||
multiple
|
||||
clearable
|
||||
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>
|
||||
<!-- 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')">
|
||||
<NSwitch v-model:value="form.includeInStats" />
|
||||
<span style="color: #999; margin-left: 8px; font-size: 12px;">{{ t('transaction.includeInStatsHint') }}</span>
|
||||
@@ -1478,9 +1772,12 @@ onMounted(() => {
|
||||
</span>
|
||||
</NFormItem>
|
||||
<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 :label="t('transaction.expenseCategory')">
|
||||
<NFormItem v-if="hasExpenseRecords" :label="t('transaction.expenseCategory')">
|
||||
<NSelect
|
||||
v-model:value="batchExpenseCategoryId"
|
||||
:options="categories.filter(c => c.type === 'EXPENSE')"
|
||||
@@ -1490,7 +1787,7 @@ onMounted(() => {
|
||||
:placeholder="t('transaction.notSetExpense')"
|
||||
/>
|
||||
</NFormItem>
|
||||
<NFormItem :label="t('transaction.incomeCategory')">
|
||||
<NFormItem v-if="hasIncomeRecords" :label="t('transaction.incomeCategory')">
|
||||
<NSelect
|
||||
v-model:value="batchIncomeCategoryId"
|
||||
:options="categories.filter(c => c.type === 'INCOME')"
|
||||
@@ -1514,10 +1811,11 @@ onMounted(() => {
|
||||
</NModal>
|
||||
|
||||
<!-- 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">
|
||||
<NAlert type="warning" :show-icon="true">{{ t('ai.applyHint') }}</NAlert>
|
||||
<NDataTable
|
||||
v-model:checked-row-keys="checkedAiRowKeys"
|
||||
:columns="aiColumns"
|
||||
:data="aiRows"
|
||||
:max-height="420"
|
||||
@@ -1527,15 +1825,15 @@ onMounted(() => {
|
||||
/>
|
||||
<NSpace justify="end">
|
||||
<NButton @click="showAiModal = false">{{ t('common.cancel') }}</NButton>
|
||||
<NButton type="primary" :loading="aiApplying" :disabled="aiRows.length === 0" @click="handleAiApply">
|
||||
{{ t('ai.applyAll', { count: aiRows.length }) }}
|
||||
<NButton type="primary" :loading="aiApplying" :disabled="checkedAiRowKeys.length === 0" @click="handleAiApply">
|
||||
{{ t('ai.applyAll', { count: aiTotalCount }) }}
|
||||
</NButton>
|
||||
</NSpace>
|
||||
</NSpace>
|
||||
</NModal>
|
||||
|
||||
<!-- 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">
|
||||
<NAlert v-if="aiQueryResult?.summary" type="info" :show-icon="true">{{ aiQueryResult.summary }}</NAlert>
|
||||
<NText v-if="aiQueryEmpty" depth="3">{{ t('ai.query.noCondition') }}</NText>
|
||||
@@ -1590,7 +1888,7 @@ onMounted(() => {
|
||||
</NModal>
|
||||
|
||||
<!-- 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">
|
||||
<NAlert type="info" :show-icon="true">{{ t('ai.record.hint') }}</NAlert>
|
||||
<NInput
|
||||
|
||||
Reference in New Issue
Block a user