企业级 AI 网关的前端核心流程是一条闭环:登录鉴权、路由守卫放行、会话管理、模型选择、发送消息、SSE 流式接收、增量渲染、历史回溯。整条链路里最容易被忽视的是状态设计——流式消息是逐步到达的,会话里同时存在”正在生成”与”已结束”两种形态,状态结构没设计好,打字机效果和多轮对话一起上就会出竞态。我用 Vue3 + Pinia + Vue Router 把这套流程落地,下面按业务顺序拆实现。
一、核心业务流程全景
网关前端的完整业务动作可以压缩成七个步骤:
- 用户登录,后端签发令牌,前端写入 store 并持久化到本地存储;
- 路由守卫校验登录态,未登录统一跳转登录页;
- 进入对话工作台,按权限加载可用的模型列表与历史会话;
- 用户选择模型、创建或切换会话,消息数组切换到对应会话上下文;
- 发送消息,消息立即上屏并标记”发送中”,同时发起流式请求;
- SSE 通道按块接收回复,逐块追加到当前会话最后一条消息;
- 流结束或出错后更新状态,落库刷新会话列表。
七个步骤分属三层:路由层管准入,store 层管状态,组件层管渲染。下面分别看每层的实现。
二、路由守卫与登录态:把鉴权挡在页面之外
2.1 全局前置守卫的三段式检查
路由守卫在跳转前做三件事:查令牌、补用户信息、查权限。核心实现如下:
router.beforeEach(async (to) => {
const userStore = useUserStore()
if (to.meta.requiresAuth && !userStore.token) {
return { path: '/login', query: { redirect: to.fullPath } }
}
if (userStore.token && !userStore.profileLoaded) {
await userStore.fetchProfile() // 一次拉取,后续复用
}
if (to.meta.permission && !userStore.hasPermission(to.meta.permission)) {
return '/403'
}
})
登录态丢失分两种情况处理:未登录访问受保护页,守卫直接重定向;请求中途令牌过期,由 Axios 响应拦截器捕获业务码后统一登出。二者配合,页面里不需要散落一堆”if 没登录就跳转”的判断。
2.2 路由懒加载与权限菜单
所有业务页面用箭头函数动态导入,交给 Vite 做代码分割。菜单由后端按角色返回,前端用 meta.permission 做按钮级过滤,模型管理、监控大盘、密钥配置这些页面按权限显示或隐藏。
三、Pinia 状态设计:流式场景下怎么组织数据
网关前端状态多而杂,我按领域拆成三个 store:
| Store | 核心状态 | 职责 |
|---|---|---|
| userStore | token、用户信息、权限集合 | 登录登出、权限判断 |
| chatStore | 会话列表、当前会话、消息数组、流式状态 | 对话主流程、SSE 消息追加 |
| gatewayStore | 模型列表、网关配置、配额余额 | 模型选择、用量展示 |
消息对象是 chatStore 的骨干,结构上必须区分生成中与已完成:
const message = {
id: 'msg_xxx',
role: 'user' | 'assistant',
content: '',
status: 'pending' | 'streaming' | 'done' | 'error',
meta: { model, tokens, duration }
}
流式阶段不新建消息对象,只在原对象上追加 content 字段、推进 status,组件按 status 决定是否渲染光标闪烁。这一步是打字机效果不卡顿的前提——频繁替换整个消息对象会触发整块 DOM 重绘。
四、消息发送与流式接收:状态机驱动
4.1 发送流程
发送消息走一个固定的动作序列:校验输入 → 更新会话标题 → 追加用户消息 → 创建空助手消息 → 发起流式连接。发送期间禁用发送按钮,防止重复提交;同一会话只允许一个进行中的流,切换会话时取消上一个流的连接。
4.2 接收流程
网关的对话接口是 SSE 流式输出,前端用 EventSource 或 fetch 流读取逐块数据,每收到一段就在消息对象上追加文本。关键节点如下:
- 建立连接后把助手消息置为 streaming;
- 收到内容块,追加到 message.content;
- 收到完成事件,状态置 done,关闭连接;
- 收到错误事件或连接断开,状态置 error,保留已生成部分并提示重试。
4.3 会话切换与竞态处理
切换会话本质是替换 chatStore 里的 currentSessionId,消息列表由 computed 按会话 id 过滤派生。竞态集中在两点:快速切换会话时上一个流的回调还在写旧消息,用 AbortController 在切换时主动中止;页面卸载时忘记关闭流,用 onUnmounted 兜底清理,避免内存泄漏。
五、请求层封装与错误处理
Axios 实例统一处理令牌注入与响应解包,后端返回固定信封结构,code 为 0 表示成功,其余归为业务错误。拦截器里做三件事:请求头带令牌;响应统一解包返回 data;401 与登录失效统一登出。页面请求代码因此保持很薄,只关心业务数据。
模型列表、会话列表这类接口的返回结构带分页与元信息,store 层用 getters 做二次加工,组件不直接触碰原始响应。图表页和列表页复用同一套请求封装,配 silentError 选项控制是否弹全局错误提示。
六、易错点与调优
第一,把 SSE 数据直接写进本地存储做历史,会造成刷新后消息一半在内存一半在存储的错乱,历史记录应以服务端落库为准。第二,权限校验只在前端做不设后端防线,前端过滤只是体验优化,后端接口必须独立鉴权。第三,多会话并发流式请求在低配浏览器上会占满连接数,HTTP/1.1 下同域连接有限,必要时把流式接口与普通接口分域部署。
常见问题(FAQ)
Q1:刷新页面后正在生成的消息怎么处理?
刷新即中断,恢复时从服务端拉历史,未完成消息标记为中断并允许重新生成。
Q2:路由守卫和接口拦截器都做鉴权,重复吗?
不重复。守卫管页面准入,拦截器管接口层令牌失效,两层职责互补。
Q3:流式消息为什么不能直接整条替换渲染?
整条替换触发全量 DOM 更新,打字机会卡顿;在原文案上追加并保留消息对象才能增量渲染。