ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Laravel 11 + Vue 3 前后端分离重构CRM系统完整指南

Laravel 11 + Vue 3 前后端分离重构CRM系统完整指南 做了快十年的 PHP 业务系统从早期的 ThinkPHP 配 jQuery 一把梭到后来用 Vue 2 搭过几个后台说实话对“客户管理系统”这种项目一直有种又爱又恨的感觉——爱的是业务清晰、模块固定、好交付恨的是老架构改起来太痛。这次我把一个在公司跑了三年的老 CRM 用 Laravel 11 Vue 3 重新撸了一遍前后端彻底分离。这篇文章就把我这次从设计到落地的完整思路、核心模块拆解、实操步骤和踩坑记录都写出来给正准备做类似系统的朋友做个参考。这套系统核心就是一个典型的 B 端客户管理系统涵盖客户档案、联系人、跟进记录、合同台账和可视化统计看板。技术栈选了 PHP 8.2 Laravel 11 做纯 API 后端前端用 Vue 3 Vite Element Plus Pinia通过 Sanctum 做 token 鉴权。适合刚准备从老式混编模式切换到前后端分离架构的团队也适合要被安排开发 CRM 但还没想好技术方案的同学抄作业。1. 整体设计为什么是 Laravel 11 Vue 3 前后端分离1.1 先聊聊老架构的痛点我原来的 CRM 是 Laravel 6 的 Blade 模板加上一堆 jQuery 操作 DOM页面状态散落在各个script块里。客户列表要重新筛选条件就得刷新整页跟进记录弹窗要传数据靠的是>// database/migrations/2025_01_01_000001_create_customers_table.php Schema::create(customers, function (Blueprint $table) { $table-id(); $table-string(name, 100)-comment(客户名称); $table-string(industry, 50)-nullable()-comment(所属行业); $table-string(source, 30)-nullable()-comment(客户来源); $table-unsignedTinyInteger(level)-default(0)-comment(客户等级 0-5); $table-unsignedBigInteger(owner_id)-nullable()-comment(负责人用户ID); $table-string(phone, 20)-nullable(); $table-string(wechat, 50)-nullable(); $table-string(address, 255)-nullable(); $table-text(remark)-nullable(); $table-softDeletes(); $table-timestamps(); $table-index([owner_id, deleted_at]); }); Schema::create(contacts, function (Blueprint $table) { $table-id(); $table-foreignId(customer_id)-constrained()-cascadeOnDelete(); $table-string(name, 50); $table-string(position, 50)-nullable()-comment(职位); $table-string(phone, 20)-nullable(); $table-string(email)-nullable(); $table-timestamps(); $table-index(customer_id); });这里有个细节值得注意owner_id是客户负责人这个字段几乎每个列表和统计都会用到所以我直接在迁移里给它加了索引并且和软删除字段组合成联合索引。老系统就是忽略了这一层客户到三万条后列表页越来越慢后来加了索引才解决。模型关系也简单Customer 里加上// app/Models/Customer.php public function contacts() { return $this-hasMany(Contact::class); } public function owner() { return $this-belongsTo(User::class, owner_id); }后端控制器只负责把参数收下来真正的业务逻辑我放到 Service 层。比如保存客户时要做判重、写跟进日志这些操作全部在CustomerService::store()里完成。这样做的好处是控制器很薄接口逻辑都是单一入口后面要加 API 版本也不会牵连一堆控制器代码。前端客户表单用了 Element Plus 的el-form配合自定义的校验规则。富文本场景在这个模块其实用得不多备注就用el-input typetextarea足够表单提交后调POST /api/customers拿到返回的 ID 再刷新列表。这里我建议列表页面不要整页刷新数据而是把新创建的客户对象推到当前列表第一页如果是第一页的话直接 unshift 进来这样交互会更顺畅减少无谓的请求开销。2.2 跟进记录与商机流程跟进记录是 CRM 里交互最频繁的模块销售每天对着它打卡。我设计了一张follow_ups表关联客户 ID、跟进人 ID、跟进方式电话、拜访、微信、邮件、跟进内容和下次跟进时间。每次跟进都可能改变客户状态所以我在同一张表里加了next_follow_up_at和status字段。状态流转方面我用了 Laravel 的枚举类来管理状态机而不是散落的常量。如果不用枚举代码里到处是if ($status 1)这种魔数后面改需求就得全局搜索替换非常容易漏。// app/Enums/CustomerStatus.php namespace App\Enums; enum CustomerStatus: int { case New 0; // 新建待跟进 case Following 1; // 跟进中 case Negotiating 2; // 商务谈判 case Won 3; // 已成交 case Lost 4; // 已流失 public function label(): string { return match($this) { self::New 新建待跟进, self::Following 跟进中, self::Negotiating 商务谈判, self::Won 已成交, self::Lost 已流失, }; } }跟进记录保存的时候服务层会做一个关联操作插入follow_ups记录的同时更新customers表的状态和下次跟进时间。为了保证这两个步骤不会发生一条成功一条失败的情况我直接把它们包在 DB 事务里// app/Services/FollowUpService.php public function store(array $data) { return DB::transaction(function () use ($data) { $followUp FollowUp::create($data); $customer Customer::findOrFail($data[customer_id]); $customer-status $data[status]; $customer-next_follow_up_at $data[next_follow_up_at] ?? null; $customer-save(); return $followUp; }); }前端这块我用了一个组合式函数把“拉取历史记录 提交新跟进 更新状态”封装成一个useFollowUps这样客户详情页和列表页的快捷跟进弹窗都能复用同一套逻辑。// crm-web/src/composables/useFollowUps.ts import { ref } from vue import { fetchCustomerFollowUps, createFollowUp } from /api/customer export function useFollowUps(customerId: number) { const list ref([]) const loading ref(false) const load async () { loading.value true try { list.value await fetchCustomerFollowUps(customerId) } finally { loading.value false } } const add async (payload: any) { await createFollowUp(customerId, payload) await load() } return { list, loading, load, add } }2.3 合同台账与回款记录合同模块我一开始想得很简单就是客户成交后录入一份合同金额、日期、备注就够了。但实际业务里经常出现“一个客户签了两份合同”“一份合同分三期回款”的情况所以我把合同和回款拆成了两张表通过外键关联。合同表字段包括合同编号系统自动生成、客户 ID、合同总金额、签订日期、开始结束日期、备注、状态。回款表记录每笔到账金额、到账日期、关联合同 ID 和经手人。这样在客户详情页就能清晰看到“合同金额 10 万已回款 6.5 万剩余 3.5 万”的实时状态。回款汇总通过 SQL 聚合查询不为零散的统计在 PHP 里做循环累加数据量一大性能会很难看。前端合同列表我用了动态表格加汇总行页脚显示合同总金额和回款总额。Element Plus 的el-table自带show-summary属性配合summary-method自定义汇总逻辑方便又少写不少代码。2.4 数据统计看板CRM 系统上线后老板最关心的就是统计看板。我的实现是在后端单独做一个 DashboardController提供多个统计接口。例如“本月新增客户数”“当前跟进中商机数”“按来源统计客户分布”“负责人业绩排行”这几个接口。前端用 ECharts 渲染图表通过 Pinia 里的一个 dashboard store 统一管理数据请求和刷新。统计接口我强烈建议用独立的聚合查询不要在前端循环调列表接口再自己数。比如按月统计新增客户的 SQL直观且快速// 新增客户按月统计 Customer::query() -selectRaw(DATE_FORMAT(created_at, %Y-%m) as month, COUNT(*) as total) -where(created_at, , now()-startOfYear()) -groupBy(month) -orderBy(month) -get();如果统计维度很多也可以在 MySQL 里建一张汇总表由定时任务每天凌晨跑一遍把前一天的数据写进汇总表查询的时候直接按日期查。对于数据量上了十万级的项目这种预聚合方案比实时 COUNT 靠谱得多。3. 实操过程从零到一搭起整套系统3.1 后端环境准备与 Laravel 11 安装我的本地环境是 PHP 8.2 Composer 2 MySQL 8.0。Laravel 11 要求 PHP 8.2 以上建议直接用 8.3因为 PHP 8.3 的性能和异常报错体验更好。如果你还在用 PHP 7.4这个项目是跑不起来的别浪费时间纠结。安装命令composer create-project laravel/laravel crm-api cd crm-api composer require laravel/sanctum php artisan install:api这里需要注意Laravel 11 的安装命令和旧版本区别不小php artisan install:api是 11 新增的它会自动发布 Sanctum 的配置文件和迁移文件创建routes/api.php并且把api中间件组注册到bootstrap/app.php里。老版本那套手动改app/Http/Kernel.php的方式在 Laravel 11 里已经不存在了很多网上教程还停留在旧写法照着做会踩坑。环境配置文件.env里我改了数据库连接、应用 URL 以及时区APP_NAMECRM System APP_URLhttp://localhost:8000 DB_CONNECTIONmysql DB_HOST127.0.0.1 DB_PORT3306 DB_DATABASEcrm DB_USERNAMEroot DB_PASSWORDyour_password APP_TIMEZONEAsia/ShanghaiLaravel 11 默认配置文件比之前精简了很多config/timezone.php是我自己新建的然后在config/app.php里加载。生产环境时区一定要统一否则统计看板里按天分组会全是偏移 8 小时的奇怪数据。3.2 数据迁移把表结构版图定下来设计好后我开始批量创建迁移文件这里我是一张表一个迁移文件并且按业务依赖顺序命名。比如先建users系统自带、customers、contacts、follow_ups、contracts、payments。指定迁移顺序不一定非得用年月日小时分钟戳硬排Laravel 就是按文件名里的时间戳排序执行迁移的所以创建客户表和联系人表的时间要错开先客户后联系人。如果是已经执行过一次迁移再来补新表直接用php artisan make:migration create_contracts_table就会自动带当前时间戳排到后面没问题。执行迁移php artisan migrate如果中途报字段错误想回滚某个表可以用php artisan migrate:rollback --step1这个命令只在还没有把迁移文件改名或者删掉时可靠。生产环境我通常直接用php artisan migrate --force来执行绝不 rollback因为生产库已经积累数据回滚迁移等于删数据太危险。3.3 API 鉴权与用户角色用户表是 Laravel 自带的我在迁移文件里加了一些字段姓名、手机号、角色。这里角色我用了一个简单的整数字段不加 spatie/laravel-permission 这种完整权限扩展因为内部系统角色就三种管理员、销售主管、销售专员权限规则用中间件判断足够。Sanctum 登录接口// app/Http/Controllers/Api/AuthController.php public function login(LoginRequest $request) { $credentials $request-only(email, password); if (! Auth::attempt($credentials)) { throw ValidationException::withMessages([ email [账号或密码错误], ]); } $user Auth::user(); $token $user-createToken(crm-token)-plainTextToken; return response()-json([ token $token, user [ id $user-id, name $user-name, email $user-email, role $user-role, ] ]); }前端拿到 token 后写到localStorage。每次请求带Authorization: Bearer token。退出登录调用POST /api/logout后端删掉当前 token。权限中间件我自定义了一个简单的角色校验// app/Http/Middleware/CheckRole.php public function handle(Request $request, Closure $next, string $role) { $user $request-user(); if (! $user || $user-role ! $role) { abort(403, 没有权限执行此操作); } return $next($request); }在routes/api.php里注册时用-middleware(auth:sanctum)保护需要登录的路由再叠加角色中间件Route::middleware(auth:sanctum)-group(function () { Route::apiResource(customers, CustomerController::class); Route::post(customers/{id}/sign-contract, [ContractController::class, store]); Route::middleware(role:admin)-get(dashboard/summary, [DashboardController::class, summary]); });CheckRole中间件需要在bootstrap/app.php里注册别名这是 Laravel 11 的新做法// bootstrap/app.php -withMiddleware(function (Middleware $middleware) { $middleware-alias([ role \App\Http\Middleware\CheckRole::class, ]); })如果之前没看过 Laravel 11 的文档很可能找不到在哪注册中间件别名这正是我先强调的地方。3.4 前端初始化Vite Vue 3 Element Plus前端我是用 Vite 直接初始化的不用 Vue CLI。Vue 官方从 Vue 3 开始就把 Vite 作为默认构建工具了打包速度比 Webpack 时代的 CLI 快好几倍开发体验完全不同npm create vitelatest crm-web -- --template vue cd crm-web npm install npm install vue-router4 pinia element-plus axios echarts安装 Element Plus 后我按需自动导入组件不用整包引入。配置vite.config.js// vite.config.js import { defineConfig } from vite import vue from vitejs/plugin-vue import AutoImport from unplugin-auto-import/vite import Components from unplugin-vue-components/vite import { ElementPlusResolver } from unplugin-vue-components/resolvers export default defineConfig({ plugins: [ vue(), AutoImport({ resolvers: [ElementPlusResolver()], }), Components({ resolvers: [ElementPlusResolver()], }), ], server: { proxy: { /api: { target: http://localhost:8000, changeOrigin: true, }, }, }, })server.proxy这段很关键。开发环境下前端跑在 5173 端口后端 API 在 8000 端口直接请求会有跨域问题。Vite 的 proxy 把前端/api开头的请求转发到后端从根本上绕开了浏览器 CORS 限制所以开发时后端甚至不需要处理跨域。生产和开发要分开看后面部署我会单独说。3.5 Pinia 与 axios 封装前端状态管理我用 Pinia核心是一个authStore存储当前登录用户和 token。axios 封装单独放在utils/request.ts做统一拦截器// crm-web/src/utils/request.ts import axios from axios import { useAuthStore } from /stores/auth import { ElMessage } from element-plus import router from /router const request axios.create({ baseURL: /api, timeout: 15000, }) request.interceptors.request.use((config) { const authStore useAuthStore() if (authStore.token) { config.headers.Authorization Bearer ${authStore.token} } return config }) request.interceptors.response.use( (response) response.data, (error) { if (error.response?.status 401) { const authStore useAuthStore() authStore.logout() router.push(/login) ElMessage.error(登录状态已失效请重新登录) } else if (error.response?.status 403) { ElMessage.error(没有权限执行此操作) } else { ElMessage.error(error.response?.data?.message || 请求失败) } return Promise.reject(error) } ) export default request这样做的好处是业务代码里不用到处写 try-catch 处理 401 和报错提示全局统一处理一次后面加新页面只需要专注业务数据逻辑。我在这一步吃过亏早期几个接口在拦截器里弹了错误提示业务代码里又弹一次用户看到一个请求报两遍错。后来约定所有接口错误提示统一由拦截器负责业务代码里catch只处理流程跳转或者静默失败。API 模块按业务拆// crm-web/src/api/customer.ts import request from /utils/request export const fetchCustomers (params: any) request.get(/customers, { params }) export const createCustomer (data: any) request.post(/customers, data) export const updateCustomer (id: number, data: any) request.put(/customers/${id}, data) export const deleteCustomer (id: number) request.delete(/customers/${id})3.6 联调开发和 Vue Router 配置在联调阶段我先把前端路由跑通配置好登录页、后台布局、客户列表、客户详情、跟进弹窗、合同管理、统计看板这些页面。路由守卫在每次跳转前检查 Pinia 里的 token 是否存没有就跳登录页。// crm-web/src/router/index.ts router.beforeEach((to) { const authStore useAuthStore() if (to.meta.requiresAuth !authStore.token) { return /login } if (to.path /login authStore.token) { return / } })Vue Router 4 用createWebHistory创建 history 模式路由URL 里不会带#好看也符合现在的标准做法。但这会给部署带来一个问题生产环境的 Nginx 要配置 try_files 回退到 index.html否则刷新子路径页面会 404。这个后面踩坑部分我单独讲。路由表我按模块组织每个模块的页面字段通过 meta 配置标题和图标侧边栏菜单直接由路由表渲染出来。这样新增页面只需要在路由表加一条记录菜单自动出现不用手动维护两份配置。4. 常见问题与排查技巧实录4.1 CORS 到底怎么配才不背锅开发环境有 Vite proxy 顶着基本不会遇到跨域问题。但一旦把前端 build 出静态文件扔到 Nginx把后端 API 部署到另一个域名或端口CORS 就出来了。这里我建议生产环境仍然用 Nginx 反代同一域名下的/api路径真正的跨域只出现在前后端完全分域部署的场景。如果必须分域处理方式是在 Laravel 后端加 CORS 中间件。Laravel 11 自带了HandleCors中间件配置在config/cors.php// config/cors.php return [ paths [api/*], allowed_methods [*], allowed_origins [https://crm.example.com], allowed_origins_patterns [], allowed_headers [*], exposed_headers [], max_age 0, supports_credentials false, ];我遇到过最坑的一种情况浏览器报了 CORS 错误但后端接口用 Postman 调完全是好的。这是因为浏览器会先发一个 OPTIONS 预检请求如果后端没有正确处理 OPTIONS就会直接拦截。Laravel 的 HandleCors 中间件会自动响应预检请求前提是中间件确实启用了。检查bootstrap/app.php里是否调用了$middleware-api(append: \Illuminate\Http\Middleware\HandleCors::class)。4.2 401 一直跳登录页前端跑起来后登录成功了但请求接口还是 401。我排查时先看浏览器的 Network 面板确认请求头里有没有Authorization: Bearer xxx。如果 token 没带上大概率是authStore没有正确初始化。Pinia 的 store 刷新后会重置所以 token 需要从 localStorage 里读出来重新放进去。我的做法是在 store 定义里初始 state 时直接读// crm-web/src/stores/auth.ts export const useAuthStore defineStore(auth, { state: () ({ token: localStorage.getItem(crm_token) || , user: JSON.parse(localStorage.getItem(crm_user) || null), }), actions: { setAuth(token: string, user: object) { this.token token this.user user localStorage.setItem(crm_token, token) localStorage.setItem(crm_user, JSON.stringify(user)) }, logout() { this.token this.user null localStorage.removeItem(crm_token) localStorage.removeItem(crm_user) }, }, })另一个容易踩的点是 axios 拦截器里访问useAuthStore()如果发生在app.use(pinia)之前会报错。通常拦截器是在请求时才执行这时应用已经完成初始化所以没问题。但如果你在模块导入阶段直接调用 useAuthStore绝对会炸记得只在函数体内调用。4.3 Laravel 11 和旧版差异导致的老教程失效Laravel 11 改的最明显的是bootstrap/app.php替代了原来app/Http/Kernel.php。中间件注册、异常处理配置全在这个文件里完成。在写上面的角色中间件时我第一反应还是去app/Http/Kernel.php里找$routeMiddleware结果发现整个文件都没了查文档才发现新写法。另外Laravel 11 默认的routes/web.php和routes/api.php都是通过bootstrap/app.php里的withRouting方法加载的。如果你看老教程说要把 API 路由文件里的前缀改成/api新版不需要install:api已经自动处理了。遇到差异最靠谱的办法是直接看项目自带的routes/api.php和bootstrap/app.php内容。框架升级后网上的文章很容易过时照着新版本项目结构理解比硬搜旧教程可靠。4.4 Vue 3 响应式数据丢更新Vue 3 的响应式机制和 Vue 2 差别很大最大的坑是解构reactive对象会丢失响应式。比如我从接口拿客户列表后想改其中一条的备注// 错误写法 const customers reactive([]) customers.value await fetchCustomers() // 正确写法 const customers ref([]) customers.value await fetchCustomers()或者组件里const form reactive({ name: , phone: }) const { name } form // 这样解构出来的是普通字符串改它不会更新 form.name解决办法尽量用ref需要展开时用toRefs。在我的项目里所有从 API 返回的列表数据统一用ref对象形式的表单初始值用reactive修改时直接通过表单绑定v-modelform.name避免解构操作。4.5 生产部署Nginx history 路由和构建产物前端打包后产物在crm-web/dist后端是 Laravel 项目我部署时用两台服务器也可以一台机器上做两个站点。Nginx 配置是必须重点说的地方特别是 history 路由模式server { listen 80; server_name crm.example.com; root /var/www/crm-web/dist; index index.html; location / { try_files $uri $uri/ /index.html; } }核心在这一行try_files $uri $uri/ /index.html。因为前端是单页应用所有路由都是前端 Router 控制的服务器上并没有对应的物理文件。如果用户直接访问/customers/123Nginx 必须返回index.html而不是 404由前端 Router 取到路径后渲染对应页面。后端 API 单独配置一个 server_name 或者用 path 区分server { listen 80; server_name api.example.com; root /var/www/crm-api/public; location / { try_files $uri $uri/ /index.php?$query_string; } location ~ \.php$ { include fastcgi_params; fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name; fastcgi_pass unix:/var/run/php/php8.3-fpm.sock; } }这时前端打包时要改 API 请求地址。我的utils/request.ts里 baseURL 是从环境变量读的开发环境走 Vite proxy 用/api生产环境用https://api.example.com/api。做法是新建.env.production让VITE_API_BASE_URL在生产构建时生效# crm-web/.env.production VITE_API_BASE_URLhttps://api.example.com/api然后request.ts里const request axios.create({ baseURL: import.meta.env.VITE_API_BASE_URL || /api, timeout: 15000, })CORS 这时候就真上阵了前端域和后端域不同需要在config/cors.php里把上面提到的allowed_origins配成前端地址。如果前端用了https而后端还是http浏览器也会拦建议生产环境直接上 HTTPS配置证书标准的 ACME 方式就行。5. 系统上线后的几个值得扩展的方向5.1 数据权限隔离CRM 上线后马上会碰到一个问题销售专员应该只能看自己的客户主管能看整个部门的客户管理员看全部。这个在后面的迭代里我加了数据权限中间件利用Customer::where(owner_id, auth()-id())控制列表默认数据源。如果要做的更精细可以在customers表加team_id字段按团队隔离。5.2 操作日志老系统最大的遗憾就是没有完整的操作日志。出了问题只能靠口头沟通还原操作过程。新系统我计划在所有写接口上加一个统一的日志中间件记录请求用户、请求路径、请求参数敏感信息脱敏、响应状态和耗时写进operation_logs表。实现不复杂用 Laravel 中间件包一层就行但价值极高建议你们在一开始就加进去。5.3 消息提醒销售场景里“今天该跟进的客户”这种需求很常见不需要推送系统只需要在客户列表页顶部放一个待办提醒查询next_follow_up_at小于等于今天且状态不是已成交/已流失的客户按负责人过滤显示条数。这个接口很轻量SQL 加上索引就够用。我在这个项目里踩过的坑上面都写完了。最后说一个体会前后端分离不是把 Blade 换成 API 就完事真正的变革在于把“页面状态”从服务端迁移到了前端管理这意味着团队里每个人都要转变思维——后端只关心数据和规则前端只关心展示和交互。如果你准备动工先从数据模型设计开始模型定好了后面写代码会顺很多。
返回列表