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
+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>