评测、批量、报告逻辑缠在一个 Vue 2 项目里,改一页动五六个文件,重构没有悬念。我们决定整体重构,选型时对比过 React、Vue 和 Angular,最终定了 Vue 3 + TypeScript + Vite,原因是团队存量就是 Vue,Composition API 对评测这种多状态交互的编排能力也更合适。平台前端是 Vue 3 + TypeScript + Vite 的现代化工程,单页应用 + 模块化设计。整个前端围绕”评测能力”和”数据可视化”两条主线,拆成 7 大模块。这篇把整体架构、模块划分、核心实现和关键取舍都过一遍。
一、技术栈总览
选型阶段我们把每个候选都跑了一遍 demo,关注点集中在类型安全、构建速度和中文生态三件事。Vue 3 的 setup 语法糖把逻辑复用往前推了一大步,Vite 的秒级 HMR 对频繁调样式的前端团队太重要了。下面是最终确定的技术栈:
| 层级 | 选型 | 理由 |
|---|---|---|
| 框架 | Vue 3.4+ | Composition API + setup 语法糖 |
| 语言 | TypeScript 5.x | 类型安全、IDE 友好 |
| 构建 | Vite 5.x | 启动快、HMR 秒级 |
| 状态 | Pinia | Vue 3 生态推荐、TS 原生 |
| 路由 | Vue Router 4 | 模块路由、懒加载 |
| UI | Element Plus | 组件丰富、中文好 |
| 图表 | ECharts 5 | 评测报告可视化 |
| HTTP | Axios | 拦截器、统一错误处理 |
| 工具 | VueUse | Composition 工具库 |
| 测试 | Vitest + Vue Test Utils | 单测 |
这个栈的收益是”写起来一致”:状态、路由、请求、组件全部走生态推荐路径,团队成员互相 review 几乎没有学习成本。TypeScript 从第一天就引入,后面团队扩张时没有再补课的痛苦。
二、模块划分
模块化是这次重构的核心目标。划分标准是”业务域 + 复用度”:能被两个以上页面复用的能力下沉为模块,单页专属的留在 views 里:
src/
├── modules/
│ ├── eval/ # 评测核心(Side-by-Side + Prompt Lab)
│ ├── batch/ # 批量测试
│ ├── scene/ # 场景与提示词管理
│ ├── model/ # 模型价目与监控
│ ├── account/ # 用户与权限
│ ├── report/ # 评测报告与可视化
│ └── admin/ # 管理后台
├── shared/ # 公共组件、工具、类型
├── stores/ # Pinia 状态
├── router/ # 路由
├── api/ # HTTP 客户端
├── views/ # 顶层页面
└── main.ts
每个模块独立 index.ts 入口,可单独打包。
这个结构的好处是”改一个模块不影响另一个”:模型价目调整只动 model 模块,评测相关的 bug 修复只进 eval 模块,git 提交记录一目了然。老项目里评测和报告互相依赖,改一处崩两处的问题在重构后再没出现过。
三、核心模块详解
1. eval 模块(评测核心)
评测模块是全平台最复杂的一块,同时跑多模型对比(SxS)和 Prompt Lab 两种场景,先看它的组件和组合式函数划分:
modules/eval/
├── components/
│ ├── EvalForm.vue # 创建评测表单
│ ├── SxSRunner.vue # SxS 多模型对比
│ ├── LabEditor.vue # Prompt Lab 编辑器
│ ├── ModelOutput.vue # 单个模型输出卡
│ ├── JudgePanel.vue # AI 评分展示
│ └── StreamingTypewriter.vue # 流式打字机
├── composables/
│ ├── useEvalSession.ts # 评测 session 状态
│ ├── useStreaming.ts # SSE 流式封装
│ └── useJudge.ts # 评分 hook
└── types.ts
useStreaming 是核心 composable,封装了流式请求的全过程:
export function useStreaming(url: string, body: any) {
const output = ref('')
const error = ref<string | null>(null)
const done = ref(false)
let es: EventSource | null = null
function start() {
// POST 不能用 EventSource,用 fetch + ReadableStream
fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body)
}).then(async res => {
const reader = res.body!.getReader()
const decoder = new TextDecoder()
while (true) {
const { done: d, value } = await reader.read()
if (d) break
const chunk = decoder.decode(value)
output.value += chunk
}
done.value = true
}).catch(e => {
error.value = e.message
})
}
return { output, error, done, start }
}
这个 composable 被 SxS 和 Prompt Lab 两个页面共用,流式逻辑只维护一份。页面里只需要拿到 output 渲染,重连和中断都由内部处理。评测场景对”首字延迟”很敏感,用 fetch 流式而非等完整返回,是体验上最关键的一步。
2. batch 模块(批量测试)
批量测试要展示”实时进度 + 按模型统计 + 失败重试”,组件拆得比较细:
batch/
├── BatchList.vue # 批次列表
├── BatchDetail.vue # 批次详情(实时进度)
├── BatchCreate.vue # 创建向导
├── components/
│ ├── ProgressChart.vue # 进度条
│ ├── ModelBreakdown.vue # 按模型统计
│ └── FailedTaskList.vue # 失败任务列表
└── composables/
└── useWebSocket.ts # WebSocket 实时进度
useWebSocket 封装重连、心跳:
export function useWebSocket(url: string) {
const data = ref<any>(null)
const connected = ref(false)
let ws: WebSocket | null = null
function connect() {
ws = new WebSocket(url)
ws.onopen = () => { connected.value = true }
ws.onmessage = e => { data.value = JSON.parse(e.data) }
ws.onclose = () => {
connected.value = false
setTimeout(connect, 3000) // 3s 重连
}
}
onUnmounted(() => ws?.close())
return { data, connected, connect }
}
WebSocket 断线重连是批量详情页的稳定性保障,断线后 3 秒自动重连,配合后端补发机制,进度不会丢。这里有个细节:onclose 里重连前要判断组件是否已卸载,否则页面切走后还会一直重连,浪费连接数。
3. scene 模块(场景/提示词管理)
场景和提示词管理本质是 CRUD,我们用 Vue Query 思路管理请求缓存:
// scene 列表
const { data: scenes } = useQuery({
queryKey: ['scenes'],
queryFn: () => api.get('/api/scene/list')
})
// 创建场景
const createScene = useMutation({
mutationFn: api.post('/api/scene', payload)
})
用 useQuery 的好处是列表缓存和失效不用手写,新增场景后 invalidateQueries 一下,列表自动刷新。对比手写 useEffect 拉数据,这个模块的样板代码少了近一半。
4. report 模块(可视化)
评测报告用 ECharts 展示评分分布,模板里直接绑定图表配置:
<!-- 用 SFC:模板 + 逻辑块 + 样式 三段式 -->
<template>
<v-chart :option="chartOption" autoresize />
</template>
<!-- 逻辑块:TS 写法 -->
<block type="ts">
const props = defineProps<{ data: ScoreDistribution[] }>()
const chartOption = computed(() => ({
xAxis: { type: 'category', data: props.data.map(d => d.range) },
yAxis: { type: 'value' },
series: [{ type: 'bar', data: props.data.map(d => d.count) }]
}))
</block>
图表配置用 computed 包一层,数据一变图表自动重绘,不用手动调 ECharts 的 setOption。报告页同时渲染多张图,全部走一个 v-chart 封装,避免重复初始化,也方便统一主题样式。
四、状态管理(Pinia)
状态管理从 Vuex 换成 Pinia,感受是模板代码少了约 60%。平台按业务域拆分 store:
// stores/user.ts
export const useUserStore = defineStore('user', () => {
const userId = ref<number | null>(null)
const token = ref<string>('')
const isLogin = computed(() => !!token.value)
async function login(form: LoginForm) {
const res = await api.post('/api/auth/login', form)
token.value = res.token
userId.value = res.userId
}
function logout() {
token.value = ''
userId.value = null
}
return { userId, token, isLogin, login, logout }
})
// stores/eval.ts
export const useEvalStore = defineStore('eval', () => {
const currentSession = ref<EvalSession | null>(null)
const items = ref<EvalItem[]>([])
function setSession(s: EvalSession) {
currentSession.value = s
items.value = s.items
}
function appendItemChunk(itemId: number, chunk: string) {
const it = items.value.find(i => i.id === itemId)
if (it) it.output += chunk
}
return { currentSession, items, setSession, appendItemChunk }
})
eval store 的 appendItemChunk 是流式输出共享的关键:模型输出的 chunk 从评测页面写入 store,报告页面用 computed 订阅,两个页面之间不需要事件总线。setup 写法比 options API 的 mapState/mapMutations 直观太多,类型推导也全自动。
五、路由设计
路由按模块懒加载,登录页和主框架分开,保证首屏加载最小:
const routes = [
{ path: '/', redirect: '/dashboard' },
{ path: '/login', component: () => import('@/views/Login.vue') },
{
path: '/eval',
component: () => import('@/views/EvalLayout.vue'),
meta: { requiresAuth: true },
children: [
{ path: '', component: () => import('@/modules/eval/EvalHome.vue') },
{ path: 'sxs/:id?', component: () => import('@/modules/eval/SxSPage.vue') },
{ path: 'lab/:id?', component: () => import('@/modules/eval/LabPage.vue') }
]
},
{
path: '/batch',
component: () => import('@/views/BatchLayout.vue'),
meta: { requiresAuth: true },
children: [
{ path: '', component: () => import('@/modules/batch/BatchList.vue') },
{ path: ':id', component: () => import('@/modules/batch/BatchDetail.vue') }
]
},
// ... admin / report / account
]
路由懒加载按需打包,首屏只加载 100KB 资源。
我们把 requiresAuth 放在 meta 里,由全局守卫统一校验,业务模块不关心登录逻辑。懒加载的收益在首屏数据上很直观:分模块打包后 gzip 只有 280KB,首屏 1.2 秒内可交互,相比老项目首屏 3 秒+ 是明显提升。
六、API 客户端
所有请求走统一 Axios 实例,拦截器处理鉴权、错误和 loading:
// api/client.ts
const http = axios.create({
baseURL: import.meta.env.VITE_API_BASE,
withCredentials: true,
timeout: 30_000
})
http.interceptors.request.use(config => {
const userStore = useUserStore()
if (userStore.token) {
config.headers.token = userStore.token
}
return config
})
http.interceptors.response.use(
res => res.data.code === 0 ? res.data : Promise.reject(res.data),
err => {
if (err.response?.status === 401) {
useUserStore().logout()
router.push('/login')
}
return Promise.reject(err)
}
)
export const api = {
get: <T>(url: string, params?: any) =>
http.get(url, { params }).then(r => r.data.data as T),
post: <T>(url: string, data?: any) =>
http.post(url, data).then(r => r.data.data as T),
// ... put, delete
}
统一拦截器处理鉴权、错误提示、loading。
401 统一跳登录是省心的设计:任何接口返回 401,全局拦截器清空登录态并跳转,业务代码不用每个请求都写一遍判断。返回结构统一 { code, data, msg },业务层拿到的都是干净的 data,r.data.data as T 这一层泛型转换让接口返回天然带类型。
七、关键设计取舍
重构过程中每个关键决策都对比过替代方案,最终取舍如下:
| 决策 | 方案 | 理由 |
|---|---|---|
| 状态管理 | Pinia 而非 Vuex | Vue 3 生态推荐、TS 友好 |
| 表单 | Element Plus + 自研 hooks | 减少学习成本 |
| 类型 | 全 TypeScript | 团队规模上去后必备 |
| 样式 | SCSS + CSS Variables | 主题切换方便 |
| 测试 | Vitest 单测 + Playwright E2E | 关键路径覆盖 |
| 部署 | Vercel | 零配置、自动 CI |
这几条取舍里,”表单自研 hooks”是团队争议比较集中的一条。Element Plus 的表单组件够用,但评测创建流程有动态字段、条件联动,自研 hooks 反而更可控。部署选 Vercel 是为了省运维,PR 合并自动构建发布,团队不用维护 CI 脚本。
八、性能指标
重构后我们对关键指标做了埋点,目标值是压测跑出来的:
| 指标 | 数值 |
|---|---|
| 首屏 FCP | < 1.2s |
| 可交互 TTI | < 2.0s |
| 包大小(gzip) | 280KB |
| 路由切换 | < 100ms |
| 内存占用 | < 150MB |
这些指标在低端 Android 和旧 Mac 上都验证过。包体 280KB 是拆模块懒加载的功劳,路由切换 100ms 内依赖组件预加载和 keep-alive,内存 150MB 以内靠虚拟滚动压住大列表。线上数据看,FCP 中位数稳定在 1.1 秒左右,比老项目快了 2 倍以上。
九、踩过的坑
- Pinia 持久化:早期用
localStorage手写持久化,配置一改就丢。改用pinia-plugin-persistedstate。 - 路由守卫顺序:登录态校验放在
beforeEach,但异步请求没等就跳转。改用await异步守卫。 - 大列表卡顿:评测历史上万行,虚拟滚动用
vue-virtual-scroller。 - SSE 断流重连:浏览器
EventSource自动重连但有上限,平台改用 fetch + ReadableStream 自控。 - Vite 别名:
@/别名 IDE 识别但生产构建不识别,要在vite.config.ts显式配。
这几个坑里,路由守卫顺序和 Vite 别名属于”配置层面的隐性坑”,排查成本高,建议写进项目 README。SSE 断流重连我们在流式打字机那篇里展开过,自研 fetch 方案虽然代码多一点,但重连完全可控。Pinia 持久化换插件后,配置丢失的问题再没出现过。
当线上反馈”页面白屏或卡死”时,我的排查顺序是固定的:
- 打开控制台看 JS 报错,先排除语法与网络错误;
- 看 Network 面板,确认接口是否 4xx/5xx,是否跨域;
- 打开 Performance 录制,看是否有长任务阻塞主线程;
- 最后查构建产物,确认是不是旧版本缓存。
按这个顺序,绝大多数白屏问题在 15 分钟内就能定位。
十、目录命名规范
- 文件名:大驼峰
EvalForm.vue - 目录名:小写 + 中划线
eval-form/ - 组件前缀:业务组件不加前缀(
EvalForm),通用组件加App(AppButton) - 组合式函数:
useXxx.ts - 类型文件:
types.ts或xxx.d.ts
命名规范看起来琐碎,但对代码检索效率影响很大。IDE 里输入 EvalForm 能精确跳到文件,useStreaming 一眼看出是组合式函数,新成员按规范写代码基本不会迷路。我们把这条写进了团队的代码规范文档,评审时作为必查项。
常见问题(FAQ)
Q1:为什么不用 Nuxt?
平台是后台工具,不需要 SSR;Vue 3 + Vite SPA 更轻量。
Q2:模块和视图怎么切分?
复用 2 次以上放 modules/views/,否则放页面内。状态/类型放 shared 或同模块。
Q3:为什么不全用 Element Plus?
复杂业务组件(评测编辑器、报告生成器)自研比二次封装 Element 简单,UI 统一性靠设计系统而非组件库。