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
+10
View File
@@ -0,0 +1,10 @@
// 通用渲染工具:为下拉选项等场景提供统一的渲染函数
import { h } from 'vue'
/** 分类下拉项渲染:图标 + 名称(下拉选项与已选项均生效) */
export function renderCategoryLabel(option: any) {
return h('span', null, [
option.icon ? h('span', { style: { marginRight: '6px' } }, option.icon) : null,
option.label,
])
}
+90
View File
@@ -0,0 +1,90 @@
// SSE 客户端工具:基于 fetch + ReadableStream 手动解析 Server-Sent Events
// EventSource 无法自定义请求头,只能把 token 放进 URL 查询参数,会泄露到访问日志;
// 这里改用 fetch 携带 Authorization 请求头,避免敏感信息出现在 URL 中。
import { useUserStore } from '../store/user'
/** SSE 事件回调集合 */
export interface SseOptions {
/** 收到事件时回调,event 为事件名,data 为已合并多行的数据 */
onEvent: (event: string, data: string) => void
/** 连接异常(含认证失败、网络错误)时回调 */
onError?: (error: unknown) => void
/** 连接正常结束时回调 */
onClose?: () => void
}
/**
* 建立 SSE 连接。
*
* @param url 相对或绝对地址(不含 token 查询参数)
* @param options 事件回调
* @returns 用于中断连接的 AbortController
*/
export function openSse(url: string, options: SseOptions): AbortController {
const controller = new AbortController()
const userStore = useUserStore()
fetch(url, {
headers: {
Authorization: `Bearer ${userStore.token}`,
Accept: 'text/event-stream',
},
signal: controller.signal,
})
.then(async (response) => {
if (!response.ok || !response.body) {
throw new Error(`SSE 连接失败:${response.status}`)
}
const reader = response.body.getReader()
const decoder = new TextDecoder()
let buffer = ''
// 持续读取并按空行切分事件报文
for (;;) {
const { done, value } = await reader.read()
if (done) break
buffer += decoder.decode(value, { stream: true })
let separator = buffer.indexOf('\n\n')
while (separator >= 0) {
dispatchEvent(buffer.slice(0, separator), options)
buffer = buffer.slice(separator + 2)
separator = buffer.indexOf('\n\n')
}
}
options.onClose?.()
})
.catch((error: unknown) => {
// 主动中断不视为错误
if ((error as Error)?.name === 'AbortError') return
options.onError?.(error)
})
return controller
}
/**
* 解析单个 SSE 报文,合并多行 data 后回调。
*
* @param chunk 不含结尾空行的单个事件报文
* @param options 事件回调
*/
function dispatchEvent(chunk: string, options: SseOptions) {
let event = 'message'
const dataLines: string[] = []
for (const rawLine of chunk.split('\n')) {
const line = rawLine.endsWith('\r') ? rawLine.slice(0, -1) : rawLine
// 空行与以冒号开头的注释行(心跳)直接跳过
if (!line || line.startsWith(':')) continue
const colon = line.indexOf(':')
const field = colon < 0 ? line : line.slice(0, colon)
let value = colon < 0 ? '' : line.slice(colon + 1)
if (value.startsWith(' ')) value = value.slice(1)
if (field === 'event') {
event = value
} else if (field === 'data') {
dataLines.push(value)
}
}
if (dataLines.length > 0) {
options.onEvent(event, dataLines.join('\n'))
}
}