【WMS 仓储系统集成 AI Agent 实战】第 7 讲:Vue3 前端工程化——Token 刷新锁、Markdown 渲染踩坑与权限体系

文章来源声明: 原文作者:运行时异常; 来源站点:掘金; 原文链接:https://juejin.cn/post/7689485292672991286; 本文基于上述来源整理/加工,觅优补充点评,仅供技术学习交流。版权归原作者所有。
觅优短评

面向中高级前端的实战复盘,Token刷新锁、双401路径与Markdown白名单三条经验可直接复用;适合做AI对话类后台系统的团队参考。

> 前一讲把 RAG 讲完了,这一讲专门讲前端工程化。核心内容:Pinia 双 Store 怎么分、并发刷新的"单飞+排队"模式(第 3 讲讲过设计,这讲补全实现)、Markdown 渲染让版式起飞的坑、以及 RBAC 权限的前端实现。

项目结构

src/
├── api/
│   ├── request.js          <span># Axios 封装 + Token 刷新拦截器(本项目最核心的工程代码)</span>
│   ├── ai.js               <span># AI/认证/知识库 API(含 SSE fetch)</span>
│   ├── erp.js              <span># 仓储业务 API</span>
│   └── system.js           <span># 系统管理 API</span>
├── components/
│   ├── GlobalLayout.vue    <span># 主布局(侧边栏+顶栏+内容区)</span>
│   └── PermissionBtn.vue   <span># 权限按钮</span>
├── router/index.js         <span># 路由 + 导航守卫</span>
├── stores/
│   ├── user.js             <span># 用户 + Token 管理</span>
│   └── chat.js             <span># 对话 + 会话管理</span>
├── utils/auth.js           <span># JWT 解析 / 过期校验</span>
└── views/                  <span># 14 个页面组件</span>
    ├── ai/                 <span># ChatWorkbench / KnowledgeBase / AiConfig</span>
    ├── erp/                <span># Material / Stock / InOrder / OutOrder / QualityInspection</span>
    └── system/             <span># UserManage / OperationLog / AiChatLog</span>

依赖全家桶(package.json 实际版本):Vue 3.5.13 + Vue Router 4.5 + Pinia 2.3 + Axios 1.7.9 + Element Plus 2.9.1 + markdown-it 14.1 + Vite 6.2.4,包管理器 pnpm。

本讲复现环境与版本

项版本/说明
Vue3.5.13(Composition API + `<script setup>`)
Vue Router / Pinia4.5 / 2.3(history 路由模式)
Axios1.7.9(拦截器 + 独立刷新实例)
Element Plus2.9.1
markdown-it14.1.0(zero 预设 + 白名单)
Vite6.2.4 + pnpm
浏览器Chrome

本讲问题均按「版本号 → 复现环境 → 真实报错 → 项目实际现象」四要素记录。前端的问题多数不抛异常,现象就是报错——版式错乱、token 丢失、请求死循环。


Pinia 双 Store 的职责切分

user.js:认证态的唯一权威

js

<span>export</span> const useUserStore = defineStore(<span>'user'</span>, () => {
  // 五个状态,与 localStorage 双写
  const token = ref(localStorage.getItem(<span>'token'</span>) || <span>''</span>)
  const refreshToken = ref(localStorage.getItem(<span>'refreshToken'</span>) || <span>''</span>)
  const role = ref(localStorage.getItem(<span>'role'</span>) || <span>''</span>)
  const userName = ref(localStorage.getItem(<span>'userName'</span>) || <span>''</span>)
  const expiresAt = ref(Number(localStorage.getItem(<span>'expiresAt'</span>) || <span>'0'</span>))

  const isAdmin = computed(() => role.value === 'ADMIN')

  // 刷新 Token 用独立 axios 实例——不走主拦截器,防死循环
  async function doRefreshToken() {
    const rt = refreshToken.value || localStorage.getItem('refreshToken')
    if (!rt) return false
    try {
      const res = await refreshClient.post('/api/auth/refresh', { refreshToken: rt })
      if (res.data?.code === <span>200</span> && res.data?.data?.accessToken) {
        applyLoginData(res.data.data)
        return true
      }
    } catch (e) { console.error('刷新 Token 失败:', e) }
    return false
  }

  function logout() {
    token.value = ''; refreshToken.value = ''; role.value = ''
    userName.value = ''; expiresAt.value = <span>0</span>
    localStorage.clear()
  }
  ...
})

为什么 localStorage 双写而不是纯 Pinia? 刷新页面 Pinia 内存态丢失,localStorage 是持久层。代价是 localStorage 用户可见可改(F12 直接改 role 成 ADMIN),但这只是前端展示层的权限——后端接口的 RBAC 校验是硬闸门,前端绕过没意义。前端权限只做体验,后端权限才是安全。

chat.js:对话态 + 一个重要修复js

<span>export</span> const useChatStore = defineStore(<span>'chat'</span>, () => {
  const messages = ref([])

  // 旧版:更新<span>"最后一条 AI 消息"</span>
  <span>function</span> updateLastMessage(chunk) {
    const last = messages.value[messages.value.length - 1]
    <span>if</span> (last && last.role === <span>'ai'</span>) last.content += chunk   // 最后一条不是 AI 时静默丢失!
  }

  // 修复版:按索引追加
  <span>function</span> updateMessageAt(idx, chunk) {
    const msg = messages.value[idx]
    <span>if</span> (!msg) <span>return</span>
    <span>if</span> (typeof msg.content !== <span>'string'</span>) msg.content = <span>''</span>
    msg.content += chunk
  }

  <span>function</span> appendToolCallToMessage(idx, toolCall) { ... }
  <span>function</span> appendToolResultToMessage(idx, toolResult) { ... }
})

updateLastMessage → updateMessageAt 这个修复背后是个真实 bug:

📋 问题档案

  • 版本:Vue 3.5.13 + Pinia 2.3
  • 复现环境:一次触发工具调用的流式对话(先收到 tool_call 事件,随后文字 token 持续到达)
  • 真实报错:无异常,无警告——内容就是静默丢失
  • 项目实际现象:AI 回复生成到一半时,工具卡片作为独立消息插入列表;此后再到达的所有文字 token 全部消失,AI 气泡停在半句话,直到下一个新会话才恢复。

根因:流式输出进行中,tool_call 卡片会作为独立消息插进列表,此时"最后一条"已经不是 AI 气泡了,后续 token 追加到了工具卡片消息上(或因角色判断失败被丢弃)。流式场景永远用索引定位,不用"最后一条"。


Axios 拦截器:三层刷新的完整实现

第 3 讲讲过设计,这里补全实现里容易漏的边角。

死循环防护

登录接口本身不能触发刷新逻辑:

js

const isAuthApi = config.url && (
  config.url.includes(<span>'/api/auth/login'</span>) ||
  config.url.includes(<span>'/api/auth/register'</span>) ||
  config.url.includes(<span>'/api/auth/refresh'</span>)
)
<span>if</span> (isAuthApi) <span>return</span> config   // 直接放行,不带 token 不刷新

刷新失败的响应也不允许再触发刷新:

js

<span>if</span> (res.code === 401) {
  <span>if</span> (config.url && config.url.includes(<span>'/api/auth/refresh'</span>)) {
    handle401(res.message || <span>'登录已过期,请重新登录'</span>)   // 直接登出,不再重试
    <span>return</span> Promise.reject(...)
  }
  ...
}

业务码 401 和 HTTP 401 双路径

后端有两种 401 形态:业务码(HTTP 200 + body 里 code=401)和标准 HTTP 401。两条路径都要处理:

js

// 响应成功(HTTP 200)但业务码 401
async response => {
  const res = response.data
  <span>if</span> (res.code === 200) <span>return</span> res
  <span>if</span> (res.code === 401) {
    const ok = await tryRefreshToken()
    <span>if</span> (ok) {
      response.config.headers.Authorization = `Bearer <span>${localStorage.getItem('token')}</span>`
      <span>return</span> request(response.config)          // 重放原请求
    }
    handle401(<span>'登录已过期,请重新登录'</span>)
  }
  ...
}

// HTTP 层 401(网关/Security 直接拦截)
async error => {
  <span>if</span> (error.response?.status === 401) {
    const ok = await tryRefreshToken()
    <span>if</span> (ok) { /* 重放 */ }
    handle401(<span>'登录已过期,请重新登录'</span>)
  }
  ...
}

为什么会有两种 401? Security 过滤器链抛的异常走 HTTP 401(body 是 Security 的错误 JSON,没有我们统一的 code 字段);业务代码里 Result.error(401, ...) 走 HTTP 200 + code=401。前端必须两条都接。

403 的处理差异

401 是"没登录/token 坏了",403 是"登录了但没权限"——403 不该登出,应该跳提示页:

js

<span>if</span> (res.code === 403) { router.push(<span>'/403'</span>); <span>return</span> Promise.reject(new Error(<span>'无权限'</span>)) }


Markdown 渲染:版式为什么"起飞"了

AI 回复用 markdown-it 渲染,上线后用户反馈:回答里随机出现巨大字号标题和加粗。

📋 问题档案

  • 版本:markdown-it 14.1.0(默认 commonmark 预设)
  • 复现环境:任意一场流式对话,观察 AI 回复渲染过程中的中间态;或后端回复里含 --- / **文本** 的业务数据
  • 真实报错:无异常——渲染"成功"了,只是渲染错了
  • 项目实际现象:流式输出过程中,正文里某一行突然变大变成标题、部分文字变成加粗;输出完成后版式部分恢复但仍有残留。同一句话刷新页面后显示正常——只有流式中间态 + 特定字符组合才触发

根因(两层)

第一层:流式渲染的中间态。逐 token 渲染时,markdown 是残缺的:

完整句:上面的方案 <span>### 注意事项</span>
残缺态:上面的方案 <span>###     ← 这一行在 Setext 语法里是二级标题!</span>

markdown 的 Setext 标题语法:文字下一行跟 --- 就是一级标题,跟 === 就是二级标题。流式中间态恰好凑出这个结构,标题就"随机起飞"。

第二层:业务数据误伤。仓储单据里的 ---(分隔线意图)和 **重点**(用户笔记习惯)被默认解析器处理成 hr 和加粗。

解法:zero 预设 + 白名单启用

js

import MarkdownIt from <span>'markdown-it'</span>

// 普通渲染(完整内容)
const md = MarkdownIt(<span>'zero'</span>, { breaks: <span>true</span>, linkify: <span>true</span> })
  .<span>enable</span>([<span>'text'</span>, <span>'paragraph'</span>, <span>'newline'</span>, <span>'link'</span>, <span>'code'</span>, <span>'fence'</span>])

// 流式渲染(残缺内容)——同样配置,因为启用集相同所以中间态也安全
const mdStream = MarkdownIt(<span>'zero'</span>, { breaks: <span>true</span>, linkify: <span>true</span> })
  .<span>enable</span>([<span>'text'</span>, <span>'paragraph'</span>, <span>'newline'</span>, <span>'link'</span>, <span>'code'</span>, <span>'fence'</span>])

'zero' 预设默认关闭一切语法,然后只 enable 白名单:段落、换行、链接、行内代码、代码块。heading/strong/em/hr 全部禁用——AI 回复用普通段落足够,业务数据里的 # ** --- 从此原样显示。

划重点:AI 对话的 Markdown 渲染,默认预设(commonmark)是个坑。对话场景真正需要的语法子集很小,白名单比黑名单安全得多。


RBAC 权限的前端实现

三层控制,后端是硬闸门:

1. 路由 meta + 导航守卫

js

{ path: <span>'ai/knowledge'</span>, name: <span>'KnowledgeBase'</span>,
  component: () => import(<span>'@/views/ai/KnowledgeBase.vue'</span>),
  meta: { title: <span>'企业知识库管理'</span>, icon: <span>'Document'</span>, role: <span>'ADMIN'</span> } },

router.beforeEach((to, from, next) => {
  const token = localStorage.getItem('token')
  const role = localStorage.getItem('role')
  if (to.meta.noAuth) return next()
  if (!token) return next('/login')
  if (isTokenExpired(token)) { localStorage.clear(); <span>return</span> next(<span>'/login'</span>) }
  <span>if</span> (to.meta.role && to.meta.role !== role) <span>return</span> next(<span>'/403'</span>)   // 角色校验
  next()
})

2. 菜单动态显隐

GlobalLayout.vue 按 meta.role 过滤侧边栏菜单——USER 角色看不到知识库管理、AI 配置、系统管理入口。

3. PermissionBtn 按钮级控制

vue

<PermissionBtn role=<span>"ADMIN"</span> <span>type</span>=<span>"primary"</span> @click=<span>"openEdit"</span>>编辑</PermissionBtn>

物料的新增/编辑/删除按钮只有 ADMIN 可见,USER 只能查询。

再次强调:这三层都是体验层。后端每个接口都有 @PreAuthorize 或 Security 配置兜底,前端改 localStorage 伪造 role 拿不到任何越权数据。


知识库管理页的三个工程细节

批量上传:串行而非并行

js

async <span>function</span> <span><span>submitUpload</span></span>() {
  uploadProgress.total = fileList.value.length
  <span>for</span> (const file of fileList.value) {
    uploadProgress.current = file.name
    try {
      const formData = new FormData()
      formData.append(<span>'file'</span>, file)
      formData.append(<span>'category'</span>, uploadCategory.value || <span>'通用'</span>)
      await uploadDocument(formData)      // 后端同步解析+向量化(<span>timeout</span> 600s)
    } catch (e) { uploadProgress.failed++ }
    uploadProgress.<span>done</span>++
  }
}

为什么串行? 每个文件的后端处理 = Tika 解析 + 分片 + 逐片调 bge-m3 向量化,一个 20MB 的 PDF 要跑几十秒。并发上传会把 Ollama 的 embedding 请求队列打爆,串行反而总耗时更短且进度可见。

下载:fetch + Blob 手动处理

下载接口要带 Authorization 头,<a href> 直链做不到,用 fetch 拿 blob 再触发下载:

js

<span>function</span> downloadDoc(row) {
  const token = localStorage.getItem(<span>'token'</span>)
  fetch(`/ai/document/<span>${row.id}</span>/download`, {
    headers: token ? { Authorization: `Bearer <span>${token}</span>` } : {}
  }).<span>then</span>(res => res.blob()).<span>then</span>(blob => {
    const a = document.createElement(<span>'a'</span>)
    a.href = URL.createObjectURL(blob)
    a.download = row.docName
    a.click()
    URL.revokeObjectURL(a.href)     // 记得释放
  })
}

RAG 问答带历史支持追问

知识库对话框里发问题时,把之前的对话也传给后端——正是第 6 讲查询改写的输入:

js

const <span>history</span> = chat.messages
  .filter(m => !m.loading && m.content)
  .slice(0, -1)                       // 排除当前问题
  .map(m => ({ role: m.role, content: m.content }))
const res = await chatKnowledge({ query: question, topK: 4 }, <span>history</span>)


本讲踩坑清单

\#坑涉及版本根因解法
1流式 token 静默丢失Vue 3.5.13 + Pinia 2.3"最后一条消息"定位被工具卡片打乱updateMessageAt 按索引更新
2回复版式随机起飞markdown-it 14.1.0 默认预设Setext 语法误命中 + 业务数据 `**`/`---`MarkdownIt zero 预设 + 白名单
3刷新接口死循环Axios 1.7.9刷新失败又触发刷新isAuthApi 短路 + 独立 axios 实例
4两种 401 只处理了一种Axios 1.7.9 + Security 6.xSecurity 层 401 与业务 401 形态不同双路径都接
5批量上传超时/失败率高Ollama embedding 队列并发打爆 embedding 队列串行上传 + 实时进度

写在最后

前端工程化没有高深算法,全是"细节决定体验"的活:一个索引定位的修复、一个 Markdown 预设的选择、一个串行上传的决定。但这些恰恰是用户直接感知的部分。

最后一讲讲生产部署:Nginx 完整配置(含 SPA 路由与 API 前缀冲突的坑)、Ollama 健康检查、以及让系统扛住真实流量的 P0-P3 四层并发保护——包括那个"Semaphore 放 Flux.defer 里不生效"的压测血案。