前端整体架构与核心模块(AI 大模型评测平台的 Vue 3 实现)

评测、批量、报告逻辑缠在一个 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 持久化换插件后,配置丢失的问题再没出现过。

当线上反馈”页面白屏或卡死”时,我的排查顺序是固定的:

  1. 打开控制台看 JS 报错,先排除语法与网络错误;
  2. 看 Network 面板,确认接口是否 4xx/5xx,是否跨域;
  3. 打开 Performance 录制,看是否有长任务阻塞主线程;
  4. 最后查构建产物,确认是不是旧版本缓存。

按这个顺序,绝大多数白屏问题在 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 统一性靠设计系统而非组件库。

版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌抄袭侵权/违法违规的内容, 请发送邮件至 qiqicto@qq.com 举报,一经查实,本站将立刻删除。
赞 (0)
其AI的头像其AI普通用户

相关推荐

返回顶部