Files
ledgerly/frontend/src/views/CategoryManage.vue
T
rosercode 0a8811534b refactor: 前端 SSE 鉴权改造与公共逻辑抽取,修复统计分组空指针
- 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
2026-09-20 15:09:35 +08:00

272 lines
9.2 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>