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
This commit is contained in:
2026-09-20 15:09:35 +08:00
parent 5fb3d78ce0
commit 0a8811534b
17 changed files with 476 additions and 375 deletions
+57 -58
View File
@@ -5,7 +5,7 @@ import { useRoute, useRouter } from 'vue-router'
import { useI18n } from 'vue-i18n'
import {
useMessage, useDialog, NSpace, NButton, NDataTable, NTag, NModal, NForm, NFormItem,
NInput, NInputNumber, NSwitch, NSelect, NAlert, NText, NCard, NTabs, NTabPane,
NInput, NInputNumber, NSwitch, NSelect, NAlert, NText, NCard, NTabs, NTabPane, NLog,
} from 'naive-ui'
import type { DataTableColumns } from 'naive-ui'
import {
@@ -23,16 +23,16 @@ import type {
import type { ParsedBillRow, BillType, BillImportItem } from '../api/transaction'
import { getCategoryList } from '../api/category'
import { getTagList } from '../api/tag'
import { renderCategoryLabel } from '../utils/render'
import BillPreviewPanel from '../components/BillPreviewPanel.vue'
import BillImportResult from '../components/BillImportResult.vue'
import { useUserStore } from '../store/user'
import { openSse } from '../utils/sse'
const message = useMessage()
const dialog = useDialog()
const { t } = useI18n()
const route = useRoute()
const router = useRouter()
const userStore = useUserStore()
/** 待处理列表自动刷新间隔(毫秒) */
const MESSAGE_REFRESH_MS = 30000
@@ -77,14 +77,6 @@ const expenseCategories = ref<{ label: string; value: number; icon: string }[]>(
const incomeCategories = ref<{ label: string; value: number; icon: string }[]>([])
const allTags = ref<{ label: string; value: number }[]>([])
/** 分类下拉项渲染:图标 + 名称 */
function renderCategoryLabel(option: any) {
return h('span', null, [
option.icon ? h('span', { style: 'margin-right:6px' }, option.icon) : null,
option.label,
])
}
async function loadOptions() {
try {
const [expenseRes, incomeRes, tagRes]: any = await Promise.all([
@@ -104,11 +96,12 @@ const configLoading = ref(false)
const configModalVisible = ref(false)
const configEditingId = ref<number | null>(null)
const configForm = ref<EmailBillConfigPayload>(emptyConfigForm())
const configSaving = ref(false) // 邮箱配置保存状态
// ===== 立即检查实时进度 =====
const checkLogs = ref<string[]>([])
const checkRunning = ref(false)
let checkSource: EventSource | null = null
let checkController: AbortController | null = null
let checkFinished = false
function emptyConfigForm(): EmailBillConfigPayload {
@@ -206,6 +199,7 @@ function openConfigModal(row?: EmailBillConfig) {
}
async function saveConfig() {
configSaving.value = true
try {
if (configEditingId.value) {
await updateEmailBillConfig(configEditingId.value, configForm.value)
@@ -217,6 +211,8 @@ async function saveConfig() {
await loadConfigs()
} catch (e: any) {
message.error(e.message || t('emailBill.common.loadFailed'))
} finally {
configSaving.value = false
}
}
@@ -264,46 +260,45 @@ function handleCheckConfig(row: EmailBillConfig) {
checkRunning.value = true
checkFinished = false
const params = new URLSearchParams()
// EventSource 无法自定义请求头,token 通过查询参数传递
params.set('token', userStore.token || '')
const source = new EventSource(`/api/email-bill/configs/${row.id}/check/stream?${params.toString()}`)
checkSource = source
source.addEventListener('line', (e: MessageEvent) => {
checkLogs.value.push(e.data)
checkController = openSse(`/api/email-bill/configs/${row.id}/check/stream`, {
onEvent: async (event, data) => {
if (event === 'line') {
checkLogs.value.push(data)
} else if (event === 'done') {
let count = 0
try {
count = JSON.parse(data).count ?? 0
} catch {
count = 0
}
checkLogs.value.push(t('emailBill.config.checkResult', { count }))
finishCheck()
await Promise.all([loadConfigs(), loadMessages()])
} else if (event === 'failed') {
let reason = ''
try {
reason = JSON.parse(data).message ?? ''
} catch {
reason = ''
}
checkLogs.value.push(reason ? `${t('emailBill.config.checkFailed')}:${reason}` : t('emailBill.config.checkFailed'))
finishCheck()
loadConfigs()
}
},
onError: () => {
// 连接异常(含认证失败)时结束检查
if (!checkFinished) {
finishCheck()
}
},
onClose: () => {
// 服务端正常结束但未收到 done/failed 时兜底收尾
if (!checkFinished) {
finishCheck()
}
},
})
source.addEventListener('done', async (e: MessageEvent) => {
let count = 0
try {
count = JSON.parse(e.data).count ?? 0
} catch {
count = 0
}
checkLogs.value.push(t('emailBill.config.checkResult', { count }))
finishCheck()
await Promise.all([loadConfigs(), loadMessages()])
})
source.addEventListener('failed', (e: MessageEvent) => {
let reason = ''
try {
reason = JSON.parse(e.data).message ?? ''
} catch {
reason = ''
}
checkLogs.value.push(reason ? `${t('emailBill.config.checkFailed')}:${reason}` : t('emailBill.config.checkFailed'))
finishCheck()
loadConfigs()
})
source.onerror = () => {
// 连接异常或服务端结束后关闭,避免 EventSource 自动重连导致重复检查
if (!checkFinished) {
finishCheck()
}
}
}
/** 结束检查状态并关闭 SSE 连接 */
@@ -315,9 +310,9 @@ function finishCheck() {
/** 关闭当前 SSE 连接 */
function stopCheckStream() {
if (checkSource) {
checkSource.close()
checkSource = null
if (checkController) {
checkController.abort()
checkController = null
}
}
@@ -326,7 +321,8 @@ const senderRules = ref<EmailBillSenderRule[]>([])
const ruleLoading = ref(false)
const ruleModalVisible = ref(false)
const ruleEditingId = ref<number | null>(null)
const ruleForm = ref<EmailBillSenderRulePayload & { patternsText: string }>(emptyRuleForm())
const ruleForm = ref<Omit<EmailBillSenderRulePayload, 'senderPatterns'> & { patternsText: string }>(emptyRuleForm())
const ruleSaving = ref(false) // 发件人规则保存状态
function emptyRuleForm() {
return {
@@ -410,6 +406,7 @@ async function saveRule() {
incomeCategoryId: ruleForm.value.incomeCategoryId,
tagIds: ruleForm.value.tagIds,
}
ruleSaving.value = true
try {
if (ruleEditingId.value) {
await updateEmailBillSenderRule(ruleEditingId.value, payload)
@@ -421,6 +418,8 @@ async function saveRule() {
await loadSenderRules()
} catch (e: any) {
message.error(e.message)
} finally {
ruleSaving.value = false
}
}
@@ -682,7 +681,7 @@ onUnmounted(() => {
<NButton v-else size="tiny" @click="checkLogs = []">{{ t('emailBill.config.checkClear') }}</NButton>
</NSpace>
</template>
<NLog :rows="checkLogs" :loading="checkRunning" style="max-height: 240px;" />
<NLog :lines="checkLogs" :loading="checkRunning" style="max-height: 240px;" />
</NCard>
</NSpace>
</NTabPane>
@@ -801,7 +800,7 @@ onUnmounted(() => {
<template #footer>
<NSpace justify="end">
<NButton @click="configModalVisible = false">{{ t('emailBill.common.cancel') }}</NButton>
<NButton type="primary" @click="saveConfig">{{ t('emailBill.common.save') }}</NButton>
<NButton type="primary" :loading="configSaving" @click="saveConfig">{{ t('emailBill.common.save') }}</NButton>
</NSpace>
</template>
</NModal>
@@ -840,7 +839,7 @@ onUnmounted(() => {
<template #footer>
<NSpace justify="end">
<NButton @click="ruleModalVisible = false">{{ t('emailBill.common.cancel') }}</NButton>
<NButton type="primary" @click="saveRule">{{ t('emailBill.common.save') }}</NButton>
<NButton type="primary" :loading="ruleSaving" @click="saveRule">{{ t('emailBill.common.save') }}</NButton>
</NSpace>
</template>
</NModal>