项目结构
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。
本讲复现环境与版本
| 项 | 版本/说明 |
|---|---|
| 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
<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.x | Security 层 401 与业务 401 形态不同 | 双路径都接 |
| 5 | 批量上传超时/失败率高 | Ollama embedding 队列 | 并发打爆 embedding 队列 | 串行上传 + 实时进度 |
写在最后
前端工程化没有高深算法,全是"细节决定体验"的活:一个索引定位的修复、一个 Markdown 预设的选择、一个串行上传的决定。但这些恰恰是用户直接感知的部分。
最后一讲讲生产部署:Nginx 完整配置(含 SPA 路由与 API 前缀冲突的坑)、Ollama 健康检查、以及让系统扛住真实流量的 P0-P3 四层并发保护——包括那个"Semaphore 放 Flux.defer 里不生效"的压测血案。
面向中高级前端的实战复盘,Token刷新锁、双401路径与Markdown白名单三条经验可直接复用;适合做AI对话类后台系统的团队参考。