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

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

项目结构

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

依赖全家桶(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。

本讲复现环境与版本

项 版本/说明
Vue 3.5.13(Composition API + <script setup>)
Vue Router / Pinia 4.5 / 2.3(history 路由模式)
Axios 1.7.9(拦截器 + 独立刷新实例)
Element Plus 2.9.1
markdown-it 14.1.0(zero 预设 + 白名单)
Vite 6.2.4 + pnpm
浏览器 Chrome

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


Pinia 双 Store 的职责切分

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

js

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

  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 === 200 && 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 = 0
    localStorage.clear()
  }
  ...
})

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

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

bash 复制代码
export const useChatStore = defineStore('chat', () => {
  const messages = ref([])

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

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

  function appendToolCallToMessage(idx, toolCall) { ... }
  function 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

bash 复制代码
const isAuthApi = config.url && (
  config.url.includes('/api/auth/login') ||
  config.url.includes('/api/auth/register') ||
  config.url.includes('/api/auth/refresh')
)
if (isAuthApi) return config   // 直接放行,不带 token 不刷新

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

js

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

业务码 401 和 HTTP 401 双路径

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

js

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

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

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

403 的处理差异

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

js

bash 复制代码
if (res.code === 403) { router.push('/403'); return Promise.reject(new Error('无权限')) }

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

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

📋 问题档案

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

根因(两层)

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

bash 复制代码
完整句:上面的方案 ### 注意事项
残缺态:上面的方案 ###     ← 这一行在 Setext 语法里是二级标题!

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

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

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

js

bash 复制代码
import MarkdownIt from 'markdown-it'

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

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

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

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


RBAC 权限的前端实现

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

1. 路由 meta + 导航守卫

js

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

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(); return next('/login') }
  if (to.meta.role && to.meta.role !== role) return next('/403')   // 角色校验
  next()
})

2. 菜单动态显隐

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

3. PermissionBtn 按钮级控制

vue

bash 复制代码
<PermissionBtn role="ADMIN" type="primary" @click="openEdit">编辑</PermissionBtn>

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

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


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

批量上传:串行而非并行

js

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

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

下载:fetch + Blob 手动处理

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

js

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

RAG 问答带历史支持追问

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

js

bash 复制代码
const history = 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 }, history)

本讲踩坑清单

# 坑 涉及版本 根因 解法
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.x Security 层 401 与业务 401 形态不同 双路径都接
5 批量上传超时/失败率高 Ollama embedding 队列 并发打爆 embedding 队列 串行上传 + 实时进度

写在最后

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

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

相关推荐
花开路口1 小时前
深入理解 Java/Kotlin 协变与逆变
java·kotlin
她的男孩1 小时前
开放接口限流从 20 改到 200 还是每分钟 20 次:拆完防重放+幂等+限流,我找到 5 个静默失效的坑
java·后端·架构
天天被压力1 小时前
【Python 量化取数指南 #13】Python 把行情落库:sqlite 一键存,回测随用随取
java·人工智能·python
天天被压力1 小时前
【Python 量化取数指南 #14】Python 清洗行情数据:复权停牌对齐,回测不翻车
java·人工智能·python
独泪了无痕1 小时前
开发利器Hutool之MapUtil的使用
java·后端
唐青枫1 小时前
Java ThreadLocal 详解:从线程隔离到线程池实战,彻底避开脏数据与内存泄漏
java
T_Apollo1 小时前
2.6 Java 11 版本新增特性和类
java
用户3721574261351 小时前
如何使用 Java 创建 Excel 数据透视表
java
用户3126874877201 小时前
JVM 内存结构全链路:堆、栈、方法区、元空间
java