fix(web): code review pass — scroll watch, SSE same-origin, store concurrency, modal reset, admin validation

This commit is contained in:
2026-05-04 20:58:48 +08:00
parent ff283013da
commit b8d29d8ea7
11 changed files with 145 additions and 85 deletions
+51 -55
View File
@@ -1,4 +1,3 @@
import { ref } from 'vue'
import { useAuthStore } from '@/stores/auth'
export type ChatStreamEventType =
@@ -26,65 +25,62 @@ export interface ChatStreamHandle {
}
/**
* useChatStream 通过 fetch + ReadableStream 自实现 SSE 解析,
* startChatStream 通过 fetch + ReadableStream 自实现 SSE 解析,
* 以支持 Authorization Bearer 头(EventSource 不支持自定义 header)。
* start() 立即返回 stop 句柄,读流在后台 fire-and-forget。
* 同步返回 stop 句柄,读流在后台 fire-and-forget。
*
* 仅接受同源 streamUrl —— 防止上游配置错误把 Bearer token 发到外域。
*/
export function useChatStream() {
export function startChatStream(
streamUrl: string,
since: number,
h: ChatStreamHandlers,
): ChatStreamHandle {
const auth = useAuthStore()
const reading = ref(false)
function start(
streamUrl: string,
since: number,
h: ChatStreamHandlers,
): ChatStreamHandle {
const url = new URL(streamUrl, window.location.origin)
url.searchParams.set('since', String(since))
const ctrl = new AbortController()
reading.value = true
void (async () => {
try {
const headers: Record<string, string> = { Accept: 'text/event-stream' }
if (auth.token) headers['Authorization'] = `Bearer ${auth.token}`
const resp = await fetch(url.toString(), {
headers,
signal: ctrl.signal,
})
if (!resp.ok || !resp.body) {
throw new Error(`SSE HTTP ${resp.status}`)
}
const reader = resp.body.getReader()
const decoder = new TextDecoder()
let buf = ''
for (;;) {
const { value, done } = await reader.read()
if (done) break
buf += decoder.decode(value, { stream: true })
let idx = buf.indexOf('\n\n')
while (idx >= 0) {
const raw = buf.slice(0, idx)
buf = buf.slice(idx + 2)
const ev = parseSSE(raw)
if (ev) h.onEvent(ev)
idx = buf.indexOf('\n\n')
}
}
h.onClose?.()
} catch (err) {
const e = err as { name?: string }
if (e?.name === 'AbortError') return
h.onError?.(err instanceof Error ? err : new Error(String(err)))
} finally {
reading.value = false
}
})()
return { stop: () => ctrl.abort() }
const url = new URL(streamUrl, window.location.origin)
if (url.origin !== window.location.origin) {
throw new Error('refusing cross-origin SSE')
}
url.searchParams.set('since', String(since))
return { start, reading }
const ctrl = new AbortController()
void (async () => {
try {
const headers: Record<string, string> = { Accept: 'text/event-stream' }
if (auth.token) headers['Authorization'] = `Bearer ${auth.token}`
const resp = await fetch(url.toString(), {
headers,
signal: ctrl.signal,
})
if (!resp.ok || !resp.body) {
throw new Error(`SSE HTTP ${resp.status}`)
}
const reader = resp.body.getReader()
const decoder = new TextDecoder()
let buf = ''
for (;;) {
const { value, done } = await reader.read()
if (done) break
buf += decoder.decode(value, { stream: true })
let idx = buf.indexOf('\n\n')
while (idx >= 0) {
const raw = buf.slice(0, idx)
buf = buf.slice(idx + 2)
const ev = parseSSE(raw)
if (ev) h.onEvent(ev)
idx = buf.indexOf('\n\n')
}
}
h.onClose?.()
} catch (err) {
const e = err as { name?: string }
if (e?.name === 'AbortError') return
h.onError?.(err instanceof Error ? err : new Error(String(err)))
}
})()
return { stop: () => ctrl.abort() }
}
function parseSSE(raw: string): ChatStreamEvent | null {