客户关系管理系统几乎是所有企业级前端项目中最典型的场景之一:数据量大、表单复杂、权限层级深、交互密集。Salesforce之所以被称为CRM领域的标杆,不只是因为它的业务功能完善,更在于它把客户数据、商机跟进、销售漏斗、报表看板这些模块拆解得非常清晰。用Vue 3来工程化实现一套类似的CRM前端,并不是简单堆砌页面,而是要从架构设计、状态管理、路由权限到构建优化做整体规划。本文将从实际落地的角度,把整个方案掰开揉碎讲清楚。

一、项目脚手架与目录结构设计
搭建CRM项目的第一步不是写页面,而是确定目录结构。中大型项目的目录结构直接决定了后期维护成本。推荐使用Vite创建项目,它基于原生ES模块的开发服务器启动速度远快于Webpack,冷启动通常在几百毫秒内完成,这在模块动辄几百个的CRM项目里体验差距非常明显。
npm create vite@latest vue3-crm -- --template vue-ts cd vue3-crm npm install npm install pinia vue-router@4 element-plus axios
目录结构建议按业务域划分,而不是按文件类型划分。很多团队习惯把所有组件放在components目录、所有页面放在views目录,项目小的时候没问题,但CRM这种业务模块众多的系统,后期views目录会膨胀到几百个文件,找代码像大海捞针。更合理的做法是每个业务域内部自治:
src/ ├── api/ # 接口层,按模块拆分 │ ├── customer.ts │ └── opportunity.ts ├── modules/ # 业务域,每个模块内聚 │ ├── customer/ │ │ ├── views/ │ │ ├── components/ │ │ └── routes.ts │ └── opportunity/ ├── stores/ # Pinia 仓库 ├── router/ ├── utils/ └── App.vue
这种结构的最大好处是路由配置可以分散到各模块内部,通过一个自动扫描机制聚合。在router目录下写一个loadRoutes函数,用import.meta.glob批量导入各模块的routes.ts,就能实现模块的新增即插即用,不需要每次都去改一个巨大的中央路由文件。
// router/index.ts
import { createRouter, createWebHistory, type RouteRecordRaw } from 'vue-router'
const baseRoutes: RouteRecordRaw[] = [
{ path: '/login', component: () => import('@/views/Login.vue') },
{ path: '/', redirect: '/dashboard' }
]
// 自动收集各业务模块的路由配置
const moduleRoutes = Object.values(
import.meta.glob('../modules/*/routes.ts', { eager: true })
).flatMap((mod: any) => mod.default ?? [])
const router = createRouter({
history: createWebHistory(),
routes: [...baseRoutes, ...moduleRoutes]
})
export default router注意import.meta.glob的eager选项,它会把匹配的文件同步打包进来。如果希望按需加载,可以去掉eager改用动态import,但路由配置本身很小,同步加载换取更简单的代码结构是划算的。
二、Pinia状态管理与CRM数据流设计
CRM系统的状态管理有一个鲜明特点:客户数据在多个页面之间高度共享。销售列表页选中一个客户,详情页、跟进记录页、商机页都要用到同一份数据。如果每个页面各自请求接口,不仅浪费带宽,还容易出现数据不一致。用Pinia设计一个CustomerStore来统一管理是标准做法。
// stores/customer.ts
import { defineStore } from 'pinia'
import { getCustomerList, getCustomerDetail } from '@/api/customer'
export const useCustomerStore = defineStore('customer', {
state: () => ({
list: [] as Customer[],
total: 0,
current: null as Customer | null,
loading: false
}),
getters: {
highValueCustomers: (state) =>
state.list.filter(c => c.annualRevenue > 1000000)
},
actions: {
async fetchList(params: PageQuery) {
this.loading = true
try {
const { data } = await getCustomerList(params)
this.list = data.rows
this.total = data.total
} finally {
this.loading = false
}
}
}
})这里有个实际项目里非常容易踩的坑:分页数据直接替换list数组后,用户在列表页的勾选状态会丢失。解决办法是不要用组件内部的勾选状态,而是把选中项的id集合也放进store,通过row-key配合reserve-selection让Element Plus的表格跨页保留选中。这种细节在演示项目里看不出来,一到真实业务就会暴露。
另一个值得设计的数据是数据字典,也就是行业类型、客户等级、跟进阶段这类枚举值。Salesforce把这些配置抽出来做成独立管理,我们的Vue项目同样应该建一个dictStore,在应用启动时一次性拉取所有字典并缓存到localStorage。字典更新频率极低,但引用极多,全局缓存后可以避免每个页面重复请求,也能保证下拉选项的口径一致。
// stores/dict.ts
export const useDictStore = defineStore('dict', {
state: () => ({
industry: [], // 行业类型
level: [], // 客户等级
stage: [] // 商机阶段
}),
actions: {
async loadAll() {
const cached = localStorage.getItem('crm_dict')
if (cached) {
Object.assign(this.$state, JSON.parse(cached))
return
}
const { data } = await getDictAll()
Object.assign(this.$state, data)
localStorage.setItem('crm_dict', JSON.stringify(data))
}
}
})三、客户列表与详情页的核心实现
客户列表是CRM的门面。这里给出一个包含搜索、分页、批量删除的完整列表页实现,重点看组合式函数的抽取方式——把分页逻辑封装成usePagination,多个列表页都能复用。
// composables/usePagination.ts
import { reactive } from 'vue'
export function usePagination(fetchFn: (params: any) => Promise<void>) {
const page = reactive({ current: 1, size: 10, total: 0 })
async function changePage(current: number) {
page.current = current
await fetchFn({ current, size: page.size })
}
return { page, changePage }
}列表页的模板部分使用Element Plus的el-table,注意要给el-table-column配置show-overflow-tooltip,因为客户名称、备注这类字段经常超长,不处理的话表格会被撑得很难看。操作列固定在右侧,包含编辑、删除、查看详情三个按钮。删除操作必须做二次确认,批量删除要提示受影响的记录数,这是企业应用的基本交互规范。
详情页则推荐采用Tabs布局,把客户基本信息、联系人、跟进记录、关联商机分成多个标签页,对应Salesforce里的Related Lists概念。每个标签页内容用Suspense或简单的懒加载控制,切换到哪个标签才请求哪部分数据,避免首屏一次性拉取所有关联信息导致接口雪崩。跟进记录这类高频写入的模块,建议在前端做乐观更新:用户提交后立即追加到时间线,接口失败再回滚并提示,体验会好很多。
四、权限路由与动态菜单
CRM系统的权限通常按角色划分,比如销售只能看自己的客户,销售经理能看整个团队。路由层面的权限控制分两步:登录后根据用户角色过滤路由表,再用addRoute动态注册。下面是一个精简但完整的实现:
// router/permission.ts
import router from './index'
import { useUserStore } from '@/stores/user'
const whiteList = ['/login']
router.beforeEach(async (to, from, next) => {
const userStore = useUserStore()
const token = userStore.token
if (!token) {
return whiteList.includes(to.path) ? next() : next('/login')
}
if (!userStore.routesAdded) {
// 根据角色返回该用户可见的模块
const allowed = await userStore.fetchPermissions()
allowed.forEach(route => router.addRoute(route))
userStore.routesAdded = true
return next({ ...to, replace: true }) // 重新进入以匹配新路由
}
next()
})关键点是next({ ...to, replace: true })这一步。addRoute是异步生效的,如果注册完路由直接next(),当前导航可能还是匹配不到目标地址,导致白屏。重新触发一次导航是最稳妥的处理方式。菜单的渲染则直接读取过滤后的路由表,把meta中配置的title和icon展示出来,路由和菜单永远保持同源,不会出现菜单有入口但点进去404的尴尬情况。
五、构建优化与生产部署
最后聊聊工程化的收尾环节。Vite默认的产物是单vendor包,CRM项目依赖Element Plus、ECharts这类大库,不做拆分的话首屏JS能到好几兆。配置manualChunks把稳定不变的第三方库单独拆出来,配合强缓存策略,用户二次访问可以基本跳过这些下载:
// vite.config.ts
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
export default defineConfig({
plugins: [vue()],
build: {
rollupOptions: {
output: {
manualChunks: {
vendor: ['vue', 'vue-router', 'pinia', 'axios'],
element: ['element-plus'],
charts: ['echarts']
}
}
},
chunkSizeWarningLimit: 1024
}
})Element Plus建议按需自动导入,安装unplugin-vue-components和unplugin-auto-import后,组件和API的引入完全自动化,打包体积能减少一半以上。ECharts如果只用到了柱状图和折线图,一定要用按需注册的方式,只引入用到的图表组件,不要整包import echarts from 'echarts'。除此之外,路由组件全部使用动态import做懒加载,gzip压缩交给Nginx处理,配置gzip_static on即可直接服务预压缩文件,比实时压缩省CPU。
整体来看,用Vue 3工程化实现类Salesforce的CRM,技术难点不在单个功能,而在于把目录结构、状态管理、权限体系、构建优化这四件事串成一个自洽的体系。先立规矩再写代码,项目才能在业务不断膨胀的过程中保持可维护性。
Vue 3SalesforceCRM系统修改时间:2026-09-13 19:09:06