refactor(AI): 四份 SSE 请求代码合成 streamAI + useAIStream

学情分析、班级分析、班级 PK、AI 提示四处原来各有一份几乎一样的 fetch + SSE 解析,
错误处理各漏各的:

- 裸 fetch 绕过了 axios 拦截器,未登录时只显示「AI 分析生成失败」而不弹登录框。
  拦截器里的全站错误处理抽成 handleGlobalApiError,两条路共用
- 班级分析、班级 PK 离开页面不中止流。useAIStream 在作用域销毁时自动 abort
- AI 提示换了一次提交不掐上一条,旧提示会写进新提交的面板

utils/stream.ts 的 streamAI 负责请求 + 解析 + 错误翻译;组件用 useAIStream
(waiting = 第一个字还没到,running = 流没结束);学情分析在 store 里直接用 streamAI。

浏览器实跑核过:学情分析逐字流出、provider 500 时显示后端的固定中文文案、
未登录调用弹出登录框。班级 PK / 班级分析卸载即中止这条没有在浏览器里点过
(本机库里没有班级数据),逻辑在 useAIStream 的 onScopeDispose。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-09-27 15:01:43 +08:00
1 parent 09030240b8
commit c43da01ff2
7 files changed
+202 -205

No files matched your search

@@ -0,0 +1,62 @@
import { streamAI } from "utils/stream"
/**
* 组件里发 AI 流式请求:同一时刻只跑一条(再点一次会先掐掉上一条),组件卸载时
* 自动中止 —— 原来班级分析、班级 PK 两处离开页面后流还在跑,白占一个 AI 名额。
*
* - `waiting`:请求发出、第一个字还没到。按钮 loading、占位转圈用它
* - `running`:整条流还没结束
*
* `run` 失败时抛 Error(message 可直接给用户看);被中止时静默返回,不抛。
*/
export function useAIStream() {
const waiting = ref(false)
const running = ref(false)
let controller: AbortController | null = null
function abort() {
controller?.abort()
controller = null
waiting.value = false
running.value = false
}
async function run<Done = Record<string, unknown>>(
path: string,
body: unknown,
handlers: {
onDelta: (content: string) => void
onDone?: (data: Done) => void
},
) {
abort()
const own = new AbortController()
controller = own
waiting.value = true
running.value = true
try {
await streamAI<Done>(path, body, {
...handlers,
signal: own.signal,
onDelta(content) {
waiting.value = false
handlers.onDelta(content)
},
})
} catch (error) {
if (own.signal.aborted) return
throw error
} finally {
// 被下一次 run 顶掉的那条不碰状态,状态归新的那条管
if (controller === own) {
controller = null
waiting.value = false
running.value = false
}
}
}
if (getCurrentScope()) onScopeDispose(abort)
return { waiting, running, run, abort }
}