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

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

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

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

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

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

- AI 未启用时隐藏所有 AI 入口与弹窗
This commit is contained in:
2026-09-18 09:57:53 +08:00
parent 49c70a732d
commit a8ef91ed3d
30 changed files with 2009 additions and 132 deletions
+47 -4
View File
@@ -10,9 +10,11 @@ export interface AiConfig {
model: string
}
/** 单条 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 })
}
/** 一键应用建议:覆盖分类并追加标签 */
+2 -2
View File
@@ -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
+191
View File
@@ -0,0 +1,191 @@
<script setup lang="ts">
// Emoji 选择器组件:提供常用 Emoji 分组快速点选与自定义文本输入,供分类图标等场景复用
import { computed, ref, watch } from 'vue'
import { NPopover, NInput, NButton, NScrollbar } from 'naive-ui'
/** 组件属性 */
const props = withDefaults(defineProps<{
/** 当前选中的 Emoji 或图标文本 */
modelValue?: string
/** 未选择时的占位提示 */
placeholder?: string
/** 自定义输入框的占位提示 */
inputPlaceholder?: string
/** 清除按钮文案 */
clearText?: string
}>(), {
modelValue: '',
placeholder: '',
inputPlaceholder: '',
clearText: '',
})
const emit = defineEmits<{ 'update:modelValue': [value: string] }>()
/** Emoji 分组数据,按常见记账场景归类 */
const EMOJI_GROUPS: { key: string; label: string; emojis: string[] }[] = [
{ key: 'dining', label: '餐饮', emojis: ['🍚', '🍜', '🍔', '🍕', '🍰', '☕', '🍺', '🍎', '🥤', '🍦', '🍟', '🍣', '🥗', '🍲'] },
{ key: 'transport', label: '交通', emojis: ['🚗', '🚕', '🚌', '🚇', '🚄', '✈️', '🚲', '⛽', '🅿️', '🛵', '🚢', '🚝', '🚦', '🛺'] },
{ key: 'shopping', label: '购物', emojis: ['🛒', '👕', '👟', '👜', '💄', '📱', '💻', '🎁', '🧴', '👓', '⌚', '🎧', '💍', '🧢'] },
{ key: 'home', label: '居家', emojis: ['🏠', '💡', '💧', '🔥', '🛋️', '🧹', '🧺', '🪑', '🚿', '🛏️', '📺', '🧻', '🪴', '🔌'] },
{ key: 'fun', label: '娱乐', emojis: ['🎮', '🎬', '🎵', '⚽', '🏀', '🎤', '🎨', '📷', '🎳', '🏋️', '🎯', '🎪', '🎫', '🎲'] },
{ key: 'health', label: '医疗', emojis: ['💊', '🏥', '🩺', '💉', '🦷', '👶', '🧘', '🩹', '🩻', '🧑‍⚕️', '🏃', '😴'] },
{ key: 'study', label: '学习', emojis: ['📚', '✏️', '🎓', '🖊️', '📝', '🧑‍🏫', '📖', '🔬', '🗂️', '📐', '🖥️', '📅'] },
{ key: 'finance', label: '收支', emojis: ['💰', '💵', '💳', '🏦', '📈', '📉', '🧾', '🪙', '💴', '💶', '💷', '🤑', '🧧', '🏧'] },
{ key: 'other', label: '其他', emojis: ['📦', '🧳', '🐱', '🐶', '🌱', '🎉', '❤️', '⭐', '🔧', '🧸', '🍀', '🌈', '🕒', '📌'] },
]
const show = ref(false) // 选择面板显示状态
const keyword = ref(props.modelValue || '') // 自定义输入内容,与选中值同步
// 外部值变化时同步到输入框,保证编辑回填正确
watch(() => props.modelValue, (val) => {
if ((val || '') !== keyword.value) keyword.value = val || ''
})
/** 触发框展示文本:优先显示已选图标,否则显示占位提示 */
const displayText = computed(() => props.modelValue || props.placeholder)
/** 自定义输入变化时实时回写选中值 */
function handleInput(value: string) {
keyword.value = value
emit('update:modelValue', value)
}
/** 点选预设 Emoji:回写并关闭面板 */
function handleSelect(emoji: string) {
keyword.value = emoji
emit('update:modelValue', emoji)
show.value = false
}
/** 清除已选图标 */
function handleClear() {
keyword.value = ''
emit('update:modelValue', '')
show.value = false
}
</script>
<template>
<NPopover v-model:show="show" trigger="click" placement="bottom-start" :width="360">
<template #trigger>
<div class="emoji-trigger" :class="{ 'is-empty': !props.modelValue }">
<span class="emoji-current">{{ displayText }}</span>
<span class="emoji-arrow">▾</span>
</div>
</template>
<div class="emoji-panel">
<!-- 自定义输入:可直接输入任意 Emoji 或文字,兼容旧的纯文本图标 -->
<NInput
:value="keyword"
size="small"
clearable
:placeholder="props.inputPlaceholder"
@update:value="handleInput"
/>
<NScrollbar style="max-height: 260px; margin-top: 8px;">
<div v-for="group in EMOJI_GROUPS" :key="group.key">
<div class="emoji-group-label">{{ group.label }}</div>
<div class="emoji-grid">
<button
v-for="emoji in group.emojis"
:key="emoji"
type="button"
class="emoji-item"
:class="{ active: emoji === props.modelValue }"
@click="handleSelect(emoji)"
>
{{ emoji }}
</button>
</div>
</div>
</NScrollbar>
<div class="emoji-footer">
<NButton size="tiny" quaternary @click="handleClear">{{ props.clearText }}</NButton>
</div>
</div>
</NPopover>
</template>
<style scoped>
.emoji-trigger {
display: flex;
align-items: center;
justify-content: space-between;
width: 100%;
height: 34px;
padding: 0 12px;
box-sizing: border-box;
background: #fff;
border: 1px solid #e0e0e6;
border-radius: 3px;
cursor: pointer;
transition: border-color 0.2s;
}
.emoji-trigger:hover {
border-color: #36ad6a;
}
.emoji-current {
font-size: 20px;
line-height: 1;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.emoji-trigger.is-empty .emoji-current {
font-size: 14px;
color: #c2c2c2;
}
.emoji-arrow {
margin-left: 8px;
color: #999;
font-size: 12px;
}
.emoji-grid {
display: grid;
grid-template-columns: repeat(8, 1fr);
gap: 4px;
}
.emoji-item {
padding: 4px 0;
font-size: 20px;
line-height: 1;
background: transparent;
border: none;
border-radius: 6px;
cursor: pointer;
transition: background 0.15s;
}
.emoji-item:hover {
background: #f0f0f0;
}
.emoji-item.active {
background: #e6f7ec;
}
.emoji-group-label {
margin: 8px 0 4px;
font-size: 12px;
color: #999;
}
.emoji-footer {
display: flex;
justify-content: flex-end;
margin-top: 8px;
padding-top: 8px;
border-top: 1px solid #f0f0f0;
}
</style>
+51 -2
View File
@@ -150,6 +150,7 @@ export default {
pageIncome: { zh: '本页收入', en: 'Page Income' },
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: {
+1 -1
View File
@@ -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
+100 -6
View File
@@ -5,6 +5,8 @@ import { useI18n } from 'vue-i18n'
import { useMessage, useDialog, NButton, NSpace, NDataTable, NModal, NForm, NFormItem, NInput, NInputNumber, NSelect } from 'naive-ui'
import type { DataTableColumns } from 'naive-ui'
import { getCategoryList, createCategory, updateCategory, deleteCategory } from '../api/category'
import { getAiConfig, suggestEmoji, parseCategoryByAi } from '../api/ai'
import EmojiPicker from '../components/EmojiPicker.vue'
const message = useMessage() // 消息提示
const dialog = useDialog() // 对话框
@@ -26,6 +28,10 @@ const loading = ref(false) // 列表加载状态
const showModal = ref(false) // 新增/编辑弹窗显示状态
const editingId = ref<number | null>(null) // 当前编辑的分类 ID(null 表示新增)
const form = ref({ name: '', type: 'EXPENSE', icon: '', sortOrder: 0 }) // 表单数据
const aiEnabled = ref(false) // AI 是否启用,用于控制推荐入口显示
const aiLoading = ref(false) // AI 推荐请求状态
const aiText = ref('') // AI 生成分类的自然语言描述
const aiGenLoading = ref(false) // AI 生成分类请求状态
// 类型选项
const typeOptions = computed(() => [
@@ -40,8 +46,24 @@ const expenseList = computed(() => categories.value.filter(c => c.type === 'EXPE
/** 创建表格列配置 */
function createColumns(): DataTableColumns<CategoryItem> {
return [
{ title: t('common.name'), key: 'name' },
{ title: t('category.icon'), key: 'icon' },
{
title: t('common.name'), key: 'name',
render(row) {
// 名称前展示 Emoji 图标,未设置时占位
return h('span', { style: { display: 'flex', alignItems: 'center', gap: '6px' } }, [
h('span', { style: { fontSize: '18px', lineHeight: '1' } }, row.icon || '📁'),
h('span', null, row.name),
])
},
},
{
title: t('category.icon'), key: 'icon', width: 100,
render(row) {
return row.icon
? h('span', { style: { fontSize: '20px', lineHeight: '1' } }, row.icon)
: h('span', { style: { color: '#ccc' } }, '—')
},
},
{ title: t('common.sort'), key: 'sortOrder', width: 80 },
{
title: t('common.operation'), key: 'actions', width: 180,
@@ -79,6 +101,7 @@ async function fetchData() {
function handleAdd() {
editingId.value = null
form.value = { name: '', type: 'EXPENSE', icon: '', sortOrder: 0 }
aiText.value = ''
showModal.value = true
}
@@ -86,6 +109,7 @@ function handleAdd() {
function handleEdit(row: CategoryItem) {
editingId.value = row.id
form.value = { name: row.name, type: row.type, icon: row.icon || '', sortOrder: row.sortOrder }
aiText.value = ''
showModal.value = true
}
@@ -131,8 +155,64 @@ function handleDelete(id: number) {
})
}
// 组件挂载时加载分类数据
onMounted(fetchData)
/** 加载 AI 启用状态,用于控制推荐入口显示 */
async function loadAiConfig() {
try {
const res: any = await getAiConfig()
aiEnabled.value = !!res.data.enabled
} catch (e) {
// 静默处理,推荐入口保持隐藏
aiEnabled.value = false
}
}
/** AI 推荐分类 Emoji 图标 */
async function handleAiEmoji() {
if (!form.value.name) {
message.warning(t('ai.decorate.nameRequired'))
return
}
aiLoading.value = true
try {
const res: any = await suggestEmoji(form.value.name, form.value.type)
form.value.icon = res.data
} catch (e: any) {
message.error(e.message || t('ai.decorate.failed'))
} finally {
aiLoading.value = false
}
}
/** AI 根据自然语言描述生成分类,并填充名称、类型与图标 */
async function handleAiGenerate() {
if (!aiText.value.trim()) {
message.warning(t('ai.category.textRequired'))
return
}
aiGenLoading.value = true
try {
const res: any = await parseCategoryByAi(aiText.value.trim())
const data = res.data
if (!data || !data.name) {
message.warning(t('ai.category.failed'))
return
}
form.value.name = data.name
if (data.type) form.value.type = data.type
if (data.icon) form.value.icon = data.icon
message.success(t('ai.category.applied'))
} catch (e: any) {
message.error(e.message || t('ai.category.failed'))
} finally {
aiGenLoading.value = false
}
}
// 组件挂载时加载分类数据与 AI 配置
onMounted(() => {
fetchData()
loadAiConfig()
})
</script>
<template>
@@ -154,14 +234,28 @@ onMounted(fetchData)
<!-- 新增/编辑弹窗 -->
<NModal v-model:show="showModal" preset="card" :title="editingId ? t('category.editTitle') : t('category.addTitle')" style="width: 450px;">
<NForm>
<NFormItem v-if="aiEnabled" :label="t('ai.category.label')">
<div style="display: flex; gap: 8px; width: 100%;">
<NInput v-model:value="aiText" :placeholder="t('ai.category.placeholder')" @keyup.enter="handleAiGenerate" />
<NButton :loading="aiGenLoading" @click="handleAiGenerate">{{ t('ai.category.button') }}</NButton>
</div>
</NFormItem>
<NFormItem :label="t('category.name')">
<NInput v-model:value="form.name" :placeholder="t('category.namePlaceholder')" />
<div style="display: flex; gap: 8px; width: 100%;">
<NInput v-model:value="form.name" :placeholder="t('category.namePlaceholder')" />
<NButton v-if="aiEnabled" :loading="aiLoading" @click="handleAiEmoji">{{ t('ai.decorate.emojiButton') }}</NButton>
</div>
</NFormItem>
<NFormItem :label="t('category.type')">
<NSelect v-model:value="form.type" :options="typeOptions" />
</NFormItem>
<NFormItem :label="t('category.icon')">
<NInput v-model:value="form.icon" :placeholder="t('category.iconPlaceholder')" />
<EmojiPicker
v-model="form.icon"
:placeholder="t('category.iconPlaceholder')"
:input-placeholder="t('category.emojiInputPlaceholder')"
:clear-text="t('category.clearIcon')"
/>
</NFormItem>
<NFormItem :label="t('category.sort')">
<NInputNumber v-model:value="form.sortOrder" :min="0" />
+63 -5
View File
@@ -5,6 +5,7 @@ import { useI18n } from 'vue-i18n'
import { useMessage, useDialog, NButton, NSpace, NDataTable, NModal, NForm, NFormItem, NInput, NInputNumber, NColorPicker, NTag } from 'naive-ui'
import 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" />
+352 -54
View File
@@ -6,10 +6,10 @@ import { useMessage, useDialog, NButton, NSpace, NDataTable, NTag, NModal, NForm
import { EyeOutline, EyeOffOutline, CloseCircleOutline, SparklesOutline, BulbOutline } from '@vicons/ionicons5'
import 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