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:
@@ -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,
|
||||
])
|
||||
}
|
||||
@@ -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'))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user