前一讲把 RAG 讲完了,这一讲专门讲前端工程化。核心内容:Pinia 双 Store 怎么分、并发刷新的"单飞+排队"模式(第 3 讲讲过设计,这讲补全实现)、Markdown 渲染让版式起飞的坑、以及 RBAC 权限的前端实现。
项目结构
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
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
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
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
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
// 响应成功(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
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 是残缺的:
完整句:上面的方案 ### 注意事项
残缺态:上面的方案 ### ← 这一行在 Setext 语法里是二级标题!
markdown 的 Setext 标题语法:文字下一行跟 --- 就是一级标题,跟 === 就是二级标题。流式中间态恰好凑出这个结构,标题就"随机起飞"。
第二层:业务数据误伤。仓储单据里的 ---(分隔线意图)和 **重点**(用户笔记习惯)被默认解析器处理成 hr 和加粗。
解法:zero 预设 + 白名单启用
js
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
{ 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
<PermissionBtn role="ADMIN" type="primary" @click="openEdit">编辑</PermissionBtn>
物料的新增/编辑/删除按钮只有 ADMIN 可见,USER 只能查询。
再次强调:这三层都是体验层。后端每个接口都有 @PreAuthorize 或 Security 配置兜底,前端改 localStorage 伪造 role 拿不到任何越权数据。
知识库管理页的三个工程细节
批量上传:串行而非并行
js
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
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
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 里不生效"的压测血案。