- SSE 由 EventSource 改为 fetch 流式读取(新增 utils/sse.ts),token 走 Authorization 请求头,避免令牌经 URL 泄露到访问日志 - 抽取 useKeywordHistory,统一备注关键词与 AI 查询历史的读写、下拉与清空 - 抽取 renderCategoryLabel 至 utils/render.ts,消除多处重复实现 - 导出改用统一 request 实例(自动携带鉴权头),并将 blob 错误体解析为业务异常 - 新增 untagged 筛选参数;AI 智能查询改为弹窗入口并补充三语文案 - 分类/标签/邮箱/交易表单补充提交 loading,避免重复提交 - 交易时间改用本地格式化,修复 toISOString 造成的时区偏移 - 移除 Naive UI 全局注册;修正 NLog 的 rows 属性为 lines - 修复未分类记录分组时 categoryMap.get(null) 抛出的 NPE
272 lines
9.2 KiB
Vue
272 lines
9.2 KiB
Vue
<script setup lang="ts">
|
||
// 分类管理组件:展示收入/支出分类列表,支持新增、编辑、删除分类
|
||
import { ref, onMounted, computed, h } from 'vue'
|
||
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() // 对话框
|
||
const { t } = useI18n() // 国际化
|
||
|
||
/** 分类数据结构 */
|
||
interface CategoryItem {
|
||
id: number
|
||
name: string
|
||
type: string // INCOME 或 EXPENSE
|
||
icon: string
|
||
sortOrder: number
|
||
createdAt: string
|
||
}
|
||
|
||
// 列表和弹窗状态
|
||
const categories = ref<CategoryItem[]>([]) // 全部分类列表
|
||
const loading = ref(false) // 列表加载状态
|
||
const submitting = 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(() => [
|
||
{ label: t('common.types.expense'), value: 'EXPENSE' },
|
||
{ label: t('common.types.income'), value: 'INCOME' },
|
||
])
|
||
|
||
// 按类型过滤分类列表
|
||
const incomeList = computed(() => categories.value.filter(c => c.type === 'INCOME')) // 收入分类
|
||
const expenseList = computed(() => categories.value.filter(c => c.type === 'EXPENSE')) // 支出分类
|
||
|
||
/** 创建表格列配置 */
|
||
function createColumns(): DataTableColumns<CategoryItem> {
|
||
return [
|
||
{
|
||
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,
|
||
render(row) {
|
||
// 编辑和删除按钮
|
||
return [
|
||
h(NSpace, null, {
|
||
default: () => [
|
||
h(NButton, { size: 'small', onClick: () => handleEdit(row) }, { default: () => t('common.edit') }),
|
||
h(NButton, { size: 'small', type: 'error', onClick: () => handleDelete(row.id) }, { default: () => t('common.delete') }),
|
||
],
|
||
}),
|
||
]
|
||
},
|
||
},
|
||
]
|
||
}
|
||
|
||
const columns = computed(() => createColumns()) // 表格列配置(随语言切换响应式更新)
|
||
|
||
/** 加载分类列表数据 */
|
||
async function fetchData() {
|
||
loading.value = true
|
||
try {
|
||
const res: any = await getCategoryList()
|
||
categories.value = res.data
|
||
} catch (e: any) {
|
||
message.error(e.message || t('common.loadFailed'))
|
||
} finally {
|
||
loading.value = false
|
||
}
|
||
}
|
||
|
||
/** 打开新增分类弹窗,重置表单 */
|
||
function handleAdd() {
|
||
editingId.value = null
|
||
form.value = { name: '', type: 'EXPENSE', icon: '', sortOrder: 0 }
|
||
aiText.value = ''
|
||
showModal.value = true
|
||
}
|
||
|
||
/** 打开编辑分类弹窗,填充表单数据 */
|
||
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
|
||
}
|
||
|
||
/** 提交表单:校验 -> 新增或更新 -> 刷新列表 */
|
||
async function handleSubmit() {
|
||
if (!form.value.name) {
|
||
message.warning(t('category.nameRequired'))
|
||
return
|
||
}
|
||
submitting.value = true
|
||
try {
|
||
if (editingId.value) {
|
||
// 更新已有分类
|
||
await updateCategory(editingId.value, form.value)
|
||
message.success(t('common.updateSuccess'))
|
||
} else {
|
||
// 创建新分类
|
||
await createCategory(form.value)
|
||
message.success(t('common.createSuccess'))
|
||
}
|
||
showModal.value = false
|
||
await fetchData() // 刷新列表
|
||
} catch (e: any) {
|
||
message.error(e.message || t('common.operationFailed'))
|
||
} finally {
|
||
submitting.value = false
|
||
}
|
||
}
|
||
|
||
/** 删除分类:弹出确认对话框,确认后执行删除并刷新列表 */
|
||
function handleDelete(id: number) {
|
||
dialog.warning({
|
||
title: t('common.confirmDelete'),
|
||
content: t('category.deleteConfirm'),
|
||
positiveText: t('common.delete'),
|
||
negativeText: t('common.cancel'),
|
||
onPositiveClick: async () => {
|
||
try {
|
||
await deleteCategory(id)
|
||
message.success(t('common.deleteSuccess'))
|
||
await fetchData()
|
||
} catch (e: any) {
|
||
message.error(e.message || t('common.deleteFailed'))
|
||
}
|
||
},
|
||
})
|
||
}
|
||
|
||
/** 加载 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>
|
||
<div>
|
||
<!-- 顶部:标题和新增按钮 -->
|
||
<NSpace justify="space-between" style="margin-bottom: 16px;">
|
||
<h2 style="margin: 0;">{{ t('category.title') }}</h2>
|
||
<NButton type="primary" @click="handleAdd">{{ t('category.add') }}</NButton>
|
||
</NSpace>
|
||
|
||
<!-- 支出分类列表 -->
|
||
<h3>{{ t('category.expenseSection') }}</h3>
|
||
<NDataTable :columns="columns" :data="expenseList" :loading="loading" :bordered="false" />
|
||
|
||
<!-- 收入分类列表 -->
|
||
<h3 style="margin-top: 24px;">{{ t('category.incomeSection') }}</h3>
|
||
<NDataTable :columns="columns" :data="incomeList" :loading="loading" :bordered="false" />
|
||
|
||
<!-- 新增/编辑弹窗 -->
|
||
<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')">
|
||
<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')">
|
||
<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" />
|
||
</NFormItem>
|
||
<NButton type="primary" block :loading="submitting" @click="handleSubmit">{{ t('common.confirm') }}</NButton>
|
||
</NForm>
|
||
</NModal>
|
||
</div>
|
||
</template>
|