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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user