客户关系管理系统(CRM)这个题目,在毕业设计和中小型项目里出现频率相当高。后台管理系统+列表页+表单页+时间线的组合方式,业务边界清楚,既覆盖了常规的后端CRUD能力,又包含权限、数据隔离这些稍微进阶的设计点。我目前做过的几套小系统中,CRM算是比较有代表性的一个,技术栈用的Node.js + Vue,前后端分离开发。这篇文章把从业务设计到接口实现、再到前端页面的完整过程拆开讲一遍,重点回答新手最容易卡壳的地方,包括环境配置、npm脚本权限、跨域联调这些绕不开的坑。适合准备做毕设的同学,也适合想用Node.js + Vue快速落地一个小型管理系统的开发者参考。
1. 动工前的业务边界:CRM到底管哪些数据
1.1 核心业务闭环:客户从哪里来、由谁跟、多久不跟会怎样
我见过很多同学拿到CRM题目之后,第一件事就是去画页面草图,这是顺序反了。设计一个客户关系管理系统,最先要回答的是数据怎么流转,而不是页面长什么样。
我会用三个问题来校准自己对业务的理解:
- 第一个问题:客户从哪里进来?可能是地推扫码、官网留资、朋友转介绍,也可能是销售自己录入。落到系统里,这些入口对应的就是一个"来源"字段,比如枚举值设计成"线上搜索、广告投放、老客转介、线下活动、手工录入"。
- 第二个问题:客户由谁跟进?这就是经典的归属概念。每个客户应该有一个负责人(owner),负责人登录系统之后,看到的默认列表应该是"我名下的客户",而不是全量客户。
- 第三个问题:一个客户太久没人跟进会怎样?真实业务里这是很常见的场景:销售离职了、客户被遗忘了,客户资源就这么躺在某个人的名下,别人看不到,也没法去跟。所以就有了"公海池"的概念——超过一定时间没有跟进记录的客户,系统自动把他释放回公共池子,所有销售都能查看和认领。
这三个问题捋顺之后,系统的功能边界基本就固定了:客户管理、跟进记录、公海池流转、用户权限。商机、合同、回款这些属于扩展模块,安排在第二期再上也不迟。这种"先做最小闭环"的思路,对学习型项目来说非常重要,因为评审看的不是你功能多,而是流程有没有走通、设计合不合理。
1.2 四张核心表:字段怎么定、为什么要这么定
数据模型是CRM系统的地基。第一版我建议只做四张表:用户表(users)、客户表(customers)、跟进记录表(follow_records),以及一个可选的商机表(businesses)。
| 表名 | 关键字段 | 说明 |
|---|---|---|
| users | id, username, password, real_name, role, created_at | role区分管理员和销售员,password存bcrypt加密后的hash |
| customers | id, name, phone, industry, source, status, owner_id, remark, created_at, updated_at | owner_id为空表示客户在公海池 |
| follow_records | id, customer_id, user_id, content, next_time, created_at | next_time记录下次跟进时间,是公海池判断的辅助字段 |
| businesses | id, customer_id, name, amount, stage, user_id, created_at | 商机金额和阶段,第一期可做可不做 |
这里有一个特别容易被忽略的设计点:客户表的owner_id要指向users表的主键,而不是直接存一个用户名文本。虽然存文本写起来更省事,但后面要展示"这个客户归谁管"的时候,你得再查一次用户表,而且用户改名了就对不上了。用外键关联,一条JOIN就能把归属人的姓名带出来,数据一致性也好得多。
客户的status字段我定为:1-潜在客户、2-跟进中、3-已成交、4-已流失。这套状态枚举贯穿前后端,前端用Element Plus的el-tag做彩色标签,后端在接口层做参数校验,避免脏数据进来。还有一个细节:跟进记录的next_time字段很值得做,哪怕第一期不用它来触发公海池规则,它也是后续做"今日待跟进提醒"的基础。数据结构上多预留一点,后面扩展就少改一次表。
1.3 权限与数据隔离:一份代码处理两种角色
权限设计我采用最经典的RBAC思路,但只保留两个角色:admin和sales。admin能看到全公司数据,sales只能看到自己名下和公海池里的客户。
这个"数据隔离"逻辑不复杂,核心就一句话:查询客户列表的时候,根据当前登录用户的角色决定要不要拼owner_id条件。管理员不加过滤条件,销售员只查owner_id等于自己ID的数据。这个逻辑放在后端做,而不是靠前端隐藏按钮。为什么?因为前端的一切都可以被人打开浏览器控制台改掉,只有后端接口做校验才是安全的。
权限控制还要落到操作层面:一个销售员改不了别人名下的客户。就算他拿得到客户ID去调接口,后端也要校验"这个客户的owner_id到底是不是你"。这个校验写在接口里,比写在页面里可靠得多。后面第4章我会给具体实现。
2. 技术选型的真实理由:Node.js + Vue这套组合在各维度上的取舍
2.1 为什么选Node.js而不是SpringBoot
很多人在SpringBoot和Node.js之间纠结,这个问题要分场景看。
如果你的毕业设计有"必须使用Java"的硬性要求,那没得选,SpringBoot。但如果没有这个限制,我倾向于Node.js。理由很直接:做管理系统这种业务密集型应用,最大的成本是前后端两套代码的逻辑一致性。用Node.js + Express,前后端都是JavaScript(TypeScript也一样),JSON数据传输零转换成本,概念也统一。一个人开发的时候,刚写完前端的接口调用,转头去写后端路由,不用切换语言思维,效率高很多。
Express和Koa之间,我选Express。Koa的中间件模型更优雅,但是Express生态更大、资料最多、用人单位的认知度也更高。对一个需要快速落地、方便答辩时讲清楚"请求是怎么被处理的"项目来说,Express的洋葱模型足够用,而且烂大街的问题网上都有答案,卡住了能很快搜到解决方案。
2.2 前端配套:Vue 3 + Element Plus + Vite
前端技术栈我推荐:Vue 3 + Element Plus + Vite + Vue Router + Pinia + Axios。这套组合现在是Vue生态里的标准答案,没有特别冷门的选择。
为什么不推荐Vue 2 + Element UI?不是说Vue 2不能做,而是Vue 3已经稳定好几年了,组合式API写起来比选项式更清晰,Vite的启动速度也比webpack快一大截。管理系统里大量页面是表格、表单、弹窗,Element Plus的组件覆盖度完全够用。你如果照着Vue 2的历史教程来,会遇到Element UI的DatePicker在Vue 3下无法使用的兼容问题,这种坑完全没有必要踩。
状态管理直接用Pinia。Vuex 4虽然配套Vue 3,但API设计上,Pinia的去Mutations化明显更符合直觉。我的建议是,store里只放全局共享的数据,比如用户信息、登录token、基础字典,页面级的局部状态放在组件内部就好,不要什么数据都塞进store,否则过一阵子自己都看不懂数据是哪来的。
2.3 数据库:为什么选了MySQL而不是MongoDB
数据库我有过犹豫,最后还是选定了MySQL + Sequelize ORM。
CRM这种系统的表关系非常固定:客户属于用户、跟进记录属于客户,都是标准的一对多关系。这种模型的天然表达方式是关系型数据库的表和外键。MongoDB的文档模型在处理灵活字段时有优势,但客户管理系统的字段设计,第一期就能定完,根本不需要动态字段。
用Sequelize还有一个好处:它可以让你用JavaScript的Model定义来管理表结构,省去大量手写SQL的时间。对于从没系统学过SQL的同学来说,Model的写法更友好,而且Sequelize提供了完善的关联查询API,比如查询客户列表时顺带查出owner信息,一行include配置就搞定。如果你最终决定自己写原生SQL也可以,但维护成本会高一些,毕竟这是管理系统的常规业务查询,没什么需要SQL优化的地方。
3. 从环境配置到工程初始化:新手最容易卡住的环节
3.1 Node.js安装、环境变量与PowerShell脚本执行权限
Node.js的安装本身不复杂,去官网下载LTS版本(长期维护版,别下Current版),一路next就行。安装完在命令行里输入node -v和npm -v,能输出版本号就说明安装成功。
但有一个问题非常高频,高到几乎每个用Windows开发的同学都遇到过:执行npm命令时报错npm : 无法加载文件 C:\Program Files\nodejs\npm.ps1,因为在此系统上禁止运行脚本。
这个报错的原因我解释一下:npm在Windows下的可执行文件其实是一个PowerShell脚本,也就是.ps1文件,而Windows PowerShell默认的执行策略是Restricted,禁止运行任何脚本。这不是Node.js的问题,是PowerShell的安全策略问题。
解决办法是在PowerShell里调整执行策略,以管理员身份打开PowerShell,执行:
Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUserRemoteSigned的意思是:本机创建的脚本可以运行,从网络下载的脚本必须经过数字签名才能运行,npm脚本是本机安装的,可以正常执行。Scope指定为CurrentUser,就只会影响当前用户,不会影响系统层次的配置。
还有一点经常被忽略:环境变量。安装Node.js时安装器会自动把安装目录加进系统PATH,正常情况下不需要手动配置。但如果你换过安装目录,或者拿到一台别人装过的机器,可以在"系统属性-环境变量"里检查PATH中是否有Node.js的安装路径。注意,环境变量修改后要重新打开终端才会生效,新手经常在这里怀疑人生:"我明明配了为什么不行?"——因为你没重开终端。
3.2 后端工程初始化:目录结构与依赖安装
后端项目我命名为crm-server,目录结构第一版保持简单:
crm-server/ ├── app.js # 入口文件,创建应用并启动 ├── config/ │ └── db.js # 数据库连接配置 ├── models/ # Sequelize模型:Customer、User、FollowRecord等 ├── routes/ # 路由文件:auth.js、customer.js、follow.js ├── middleware/ # auth中间件、错误处理中间件 └── package.json初始化命令:
npm init -y npm install express sequelize mysql2 jsonwebtoken bcryptjs cors dotenv每个依赖的用途分别是:express是Web框架;sequelize是ORM;mysql2是连接MySQL的驱动,Sequelize依赖它;jsonwebtoken负责签发和验证JWT;bcryptjs做密码加密;cors解决跨域请求;dotenv用来读取.env配置文件,把数据库密码、JWT密钥这类敏感信息放进去,不进代码仓库。
数据库记得创建utf8mb4字符集,因为管理系统里会存客户姓名和备注,如果存在emoji字符,utf8就扛不住:
CREATE DATABASE crm DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;3.3 前端工程初始化:Vite创建Vue3项目
前端项目用Vite创建,命令很简单:
npm create vite@latest crm-web -- --template vue cd crm-web npm install npm install vue-router@4 pinia element-plus axios目录结构规划好:
crm-web/src/ ├── api/ # api请求文件,按模块拆分(auth.js、customer.js) ├── components/ # 通用组件 ├── router/ # 路由配置 ├── stores/ # Pinia状态 ├── views/ # 页面组件(Login.vue、CustomerList.vue等) ├── App.vue └── main.js我特别想强调一下api目录的价值。很多人喜欢直接在页面组件里写axios请求,小项目无所谓,但只要有三个以上页面开始复用同一个接口,痛感就来了:改一个接口地址要搜遍全项目。把请求统一放在api目录里,每个接口一个函数,页面里直接import,改地址只改一处。这个习惯越早养成越好。
4. 后端接口落地:登录鉴权、客户CRUD与公海池
4.1 接口清单与RESTful约定
后端接口按RESTful风格设计,接口清单在第一版里是这样一套:
| 方法 | 路径 | 功能 | 权限 |
|---|---|---|---|
| POST | /api/auth/login | 用户登录,返回JWT和用户信息 | 公开 |
| POST | /api/auth/register | 创建销售员账号 | admin |
| GET | /api/customers | 客户列表,支持关键词搜索、状态筛选、分页 | 登录用户,按角色隔离数据 |
| POST | /api/customers | 新增客户 | 登录用户 |
| GET | /api/customers/:id | 客户详情 | 归属人/admin |
| PUT | /api/customers/:id | 更新客户 | 归属人/admin |
| DELETE | /api/customers/:id | 删除客户 | 归属人/admin |
| GET | /api/customers/:id/follows | 某客户的跟进记录列表 | 归属人/admin |
| POST | /api/follows | 添加一条跟进记录 | 登录用户 |
| PUT | /api/customers/:id/assign | 认领公海客户或管理员分配客户 | 登录用户/admin |
为什么用/api前缀?因为后面Nginx做反向代理时,可以把所有/api请求转发给后端服务,前端静态资源和后端动态接口在同一个域名下,规避跨域。这个设计在部署阶段会省很多事。
4.2 JWT登录鉴权与角色中间件
登录接口的逻辑很直白:根据用户名查出用户,用bcryptjs比对密码,匹配之后签发JWT返回前端。
密码在数据库里绝不能存明文。注册的时候用bcryptjs的hashSync加密,登录时用compareSync比对。bcrypt的好处是有随机盐,两个相同的密码加密出来的结果也不同,即使数据库泄露,彩虹表攻击也很难生效。
JWT中间件写法如下:
const jwt = require('jsonwebtoken'); // 登录接口签发token const token = jwt.sign( { id: user.id, username: user.username, role: user.role }, process.env.JWT_SECRET, { expiresIn: '7d' } ); // 登录态校验中间件 function auth(req, res, next) { const token = req.headers.authorization?.split(' ')[1]; if (!token) { return res.status(401).json({ code: 401, message: '未登录或登录已过期' }); } try { const payload = jwt.verify(token, process.env.JWT_SECRET); req.user = payload; next(); } catch (error) { return res.status(401).json({ code: 401, message: '登录已过期,请重新登录' }); } } // 角色校验中间件:用法 router.post('/xxx', auth, role('admin'), handler) function role(requiredRole) { return (req, res, next) => { if (req.user.role !== requiredRole) { return res.status(403).json({ code: 403, message: '权限不足' }); } next(); }; }需要说明一点:JWT的密钥一定要放在.env里,不要写死在代码里。密钥泄露等于整个系统的登录验证全部失效。expiresIn设置为7d,前端收到401状态码时自动清除本地token并跳回登录页,这个联动逻辑会在第5章实现。
4.3 客户查询接口:动态条件拼接与数据隔离
客户列表接口是CRM最核心的接口,它同时要处理四件事:分页、关键词搜索、状态筛选、数据权限。我给出一个可以直接用的Express路由:
const { Op } = require('sequelize'); router.get('/customers', auth, async (req, res) => { try { const page = Math.max(parseInt(req.query.page) || 1, 1); const pageSize = parseInt(req.query.pageSize) || 10; const { keyword = '', status = '' } = req.query; const where = {}; // 数据隔离:非管理员只能看自己名下的客户 if (req.user.role !== 'admin') { where.ownerId = req.user.id; } if (keyword) { where.name = { [Op.like]: `%${keyword}%` }; } if (status) { where.status = status; } const { rows, count } = await Customer.findAndCountAll({ where, limit: pageSize, offset: (page - 1) * pageSize, include: [{ model: User, as: 'owner', attributes: ['id', 'realName'] }], order: [['createdAt', 'DESC']] }); res.json({ code: 0, data: { list: rows, total: count, page, pageSize } }); } catch (error) { res.status(500).json({ code: 500, message: '查询失败' }); } });这里有几个细节值得讲透:
parseInt取分页参数时必须做容错,用户传一个非数字字符串会导致NaN,所以我先用Math.max兜底为1。limit和offset是Sequelize的标准分页方式,前端的page从1开始,后端offset用(page - 1) * pageSize转换,这个约定前后端一定要一致,否则第二页开始数据就错位。
include数组里取出owner的id和realName,这样前端列表可以直接显示归属人名字,不需要再用客户ID发一次附加请求。用attributes限定字段,避免把密码hash这类敏感数据一并返回。
4.4 公海池定时任务:一个体现业务理解的关键功能
公海池的实现思路是这样:每24小时运行一次定时任务,把超过7天没有跟进记录的客户置为无人认领状态,也就是把owner_id置空。这个功能对项目管理类课程设计非常加分,因为它说明你理解了CRM不止是增删改查。
用node-cron实现:
const cron = require('node-cron'); cron.schedule('0 0 2 * * *', async () => { const deadline = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000); const customers = await Customer.findAll({ where: { ownerId: { [Op.ne]: null } }, include: [{ model: FollowRecord, attributes: ['createdAt'], order: [['createdAt', 'DESC']] }] }); for (const customer of customers) { const lastFollow = customer.followRecords?.[0]?.createdAt; if (!lastFollow || lastFollow < deadline) { await customer.update({ ownerId: null, status: 'public' }); } } });这种写法更推荐:判断依据是最近一条跟进记录的createdAt,而不是客户表自己的updatedAt。为什么?因为updatedAt在客户信息被编辑时会更新,一个销售可能只是改了客户电话,却导致公海池流转时间被重置,这不符合业务逻辑。真正的判断标准应该是有没有实际跟进行为。
在实现这个功能时要注意一个问题:如果客户比较多,全量查询再逐一update性能不高,但在学习项目里,上千条数据量完全够用。先把逻辑做对,再考虑性能优化,这是我一贯的原则。
5. Vue前端实现:请求层、列表页与跟进时间线
5.1 Axios封装、Pinia状态与路由守卫
前端的请求层是整个项目的交通枢纽,做得不好,后面每个页面都会写出重复的错误处理代码。我的做法是创建一个request实例,统一配置baseURL、超时时间、请求拦截器和响应拦截器:
import axios from 'axios'; import { ElMessage } from 'element-plus'; import router from '../router'; const request = axios.create({ baseURL: '/api', timeout: 10000 }); request.interceptors.request.use((config) => { const token = localStorage.getItem('token'); if (token) { config.headers.Authorization = `Bearer ${token}`; } return config; }); request.interceptors.response.use( (response) => { if (response.data.code !== 0) { ElMessage.error(response.data.message || '请求失败'); return Promise.reject(response.data); } return response.data.data; }, (error) => { if (error.response && error.response.status === 401) { localStorage.removeItem('token'); router.push('/login'); } ElMessage.error(error.response.data.message || '网络异常'); return Promise.reject(error); } );这个封装解决掉了三个重复劳动:token自动附加、业务错误统一提示、登录过期自动跳转。页面里调接口时只需要关注成功回传的数据,失败分支全部由拦截器处理。开发阶段强烈建议装Vue Devtools浏览器扩展,打开Vue面板可以实时查看路由状态和Pinia里的数据变化,排查页面数据不对的问题会快非常多。
Pinia里只保存用户信息,登录成功后把后端返回的token和用户对象写入:
export const useUserStore = defineStore('user', { state: () => ({ token: localStorage.getItem('token') || '', userInfo: null }), actions: { setLoginData(data) { this.token = data.token; this.userInfo = data.user; localStorage.setItem('token', data.token); }, logout() { this.token = ''; this.userInfo = null; localStorage.removeItem('token'); } } });路由守卫控制页面访问,没有token一律踢回登录页:
router.beforeEach((to, from, next) => { const token = localStorage.getItem('token'); if (to.path !== '/login' && !token) { next('/login'); } else if (to.path === '/login' && token) { next('/'); } else { next(); } });5.2 客户列表页:表格、搜索、分页的前后端配合
客户列表页是这个系统的门面。页面结构分三块:搜索区、表格区、分页区。我用Element Plus的el-form做横向搜索区,el-table做数据展示,el-pagination做分页。
搜索区包含三个字段:客户名称关键字、客户状态下拉框、查询按钮和重置按钮。状态下拉框的选项和后端status字段的字典保持一致:潜在客户、跟进中、已成交、已流失。
表格列我定义为:客户名称、所属行业、来源、负责人、状态、创建时间、操作。操作列放四个按钮:详情、跟进、编辑、删除。
分页这里有一个经常出错的地方:el-pagination的current-page和page-size需要双向绑定,page-change事件和size-change事件都要去调列表接口。很多人只写了current-page的change事件,改了每页条数后,当前页数还是旧的值,列表就不正常刷新。
核心的搜索和查询逻辑:
const queryParams = reactive({ page: 1, pageSize: 10, keyword: '', status: '' }); async function fetchList() { loading.value = true; try { const data = await api.getCustomers(queryParams); list.value = data.list; total.value = data.total; } finally { loading.value = false; } } function handleSearch() { queryParams.page = 1; // 搜索时重置到第一页,不然搜出来的结果可能是空页 fetchList(); }搜索后重置页码这个细节特别重要。假设你当前在第5页,数据一共6页,你搜索一个关键词,符合条件的只有3条,如果不重置页码,接口会返回空列表,页面直接白屏,用户毫无体验可言。还有,重置按钮要把所有筛选条件清空后重新查询,不只是清空输入框,还要把下拉框恢复默认值。
5.3 客户详情与跟进时间线
点击"详情"按钮进入客户详情页,路径是/customers/:id。这个页面的核心是两件事:客户基本信息的展示、跟进记录的时间线。
客户基本信息我用el-descriptions组件渲染。如果你担心页面跳转太重,也可以用el-drawer抽屉,从右侧滑出详情,省去路由跳转的切换感。两种方案我都试过,列表页操作频繁的场景下,抽屉更顺手,因为用户在右边滑出的详情看完了可以直接关掉继续操作表格。
跟进时间线是el-timeline组件的经典用法。每条跟进记录展示:跟进内容、跟进人、跟进时间、下次跟进时间。时间线按createdAt倒序排列,最新的跟进记录在最上面。
新增跟进记录我做成一个弹窗。用户在弹窗里填"本次跟进内容",选一个"下次跟进时间"(可选),提交后调POST /api/follows接口,成功了就重新拉取时间线。这里有一个体验细节:提交期间要给提交按钮加loading状态,防止用户连点两次产生重复跟进记录。这个bug在我初版项目里真实出现过,用户双击按钮,数据库里多了一条一模一样的记录,前端时间线就出现了重复项。
表单校验要跟上。客户名称必填,手机号用正则校验;跟进内容至少10个字,避免用户随手打几个字就算一次跟进,业务上这不叫跟进,叫无效点击。Element Plus的form validation rules足够用,别自己去手写正则判断然后alert报错,组件库能解决的就不重复造轮子。
6. 联调、部署与高频问题排查
6.1 开发环境跨域:Vite代理与CORS的取舍
前端跑在5173端口,后端跑在3000端口,端口不同就是跨域。这个问题的常规解法有两个:后端开CORS,或者前端配Vite代理。
后端开CORS只要一行:app.use(cors())。但这种方式在生产环境有些隐患,等于允许任意来源的跨域请求访问你的接口。更稳妥的做法是限制origin,但配置起来要维护域名白名单,有点麻烦。
我推荐的方式是开发环境用Vite代理,生产环境用Nginx反向代理。Vite的配置很简单:
// vite.config.js export default defineConfig({ server: { proxy: { '/api': { target: 'http://localhost:3000', changeOrigin: true } } } });请求层的baseURL设置成'/api',开发时Vite会把请求转发给3000端口后端,因为浏览器始终看到的是同源请求,所以不存在跨域问题。生产环境Nginx的转发规则保持一致,这样前后端代码里都不需要写死localhost,换环境只需要改部署配置。
6.2 打包部署:Nginx托管静态资源与接口反向代理
前端部署很简单,执行npm run build得到dist目录,把dist丢到Nginx的站点目录下。但这里有个经典坑:如果用Vue Router的history模式,刷新子路由页面会404。原因很简单——Nginx在磁盘上找不到/customers/1这个路径对应的文件。
解决办法是Nginx配置try_files,让所有非资源路径都回退到index.html:
server { listen 80; server_name your-domain.com; root /var/www/crm-web/dist; index index.html; location / { try_files $uri $uri/ /index.html; } location /api { proxy_pass http://127.0.0.1:3000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; } }后端用PM2管理进程,这是Node.js最常见的生产方式。安装PM2之后,一条命令启动服务、崩溃自动重启、开机自启,比裸跑node app.js强太多。常用命令:
npm install -g pm2 pm2 start app.js --name crm-server pm2 logs crm-server pm2 save推荐加--name参数给进程起个名,否则PM2面板里一堆node进程根本分不清哪个是哪个。调试接口的时候pm2 logs直接看实时日志,比自己在代码里console.log然后到处翻文件方便得多。
6.3 一批容易踩但完全能避免的细节
最后把我实际开发过程中踩过的坑集中列一下,都是很具体的问题:
日期格式。MySQL的DATETIME返回给前端是"2025-01-15T10:30:00.000Z"这种格式,直接用浏览器显示会带时区偏差。前端统一用dayjs格式化,显示为"2025-01-15 18:30"这种用户友好的格式。别忘了在Element Plus的全局配置里同时设置el-date-picker的value-format,不设的话,提交给后端的日期是Date对象,JSON序列化后格式不可控。
删除策略。客户表被跟进记录外键引用,物理删除客户会导致历史跟进记录失去关联目标。我的做法是给customers加deletedAt字段,删除时只做软删除,查询where条件统一加deletedAt: null。这个习惯一旦养成,之后所有管理系统的删除操作我都会先问一句:"这条数据删了之后会影响什么?"
分页参数一致性。前后端约定好page从1开始,pageSize默认10。这个约定要写进接口文档,前后端各自改代码,很容易出现前端传第3页,后端理解成第2页,数据错位一页的诡异问题。
空数据与null值的容错。跟进记录列表为空时,el-timeline会渲染一个空的区域,视觉上很难看,建议加一个"暂无跟进记录"的空状态提示。客户字段有空值时(如industry为空),表格显示空白比显示"null"好看多了,模板里要用逻辑判断或者写一个格式化函数。
环境变量的管理。数据库密码、JWT密钥这些写在.env文件里,.env要加进.gitignore。如果推到公开仓库里,等于把数据库密码公告天下了。这个教训我是从别人项目里看到的,但那之后我再也没把任何密钥提交进Git历史。
Vue Devtools在排查页面数据问题时的价值远超预期。很多人遇到"页面没数据"第一反应是去改模板代码,其实先打开Devtools看一眼Pinia和接口响应,大概率是接口返回的数据结构和模板期望的不一致,模板代码根本不用动。
最后说几句实际的
这套CRM系统做完,我最大的感受是:它不是一个技术难题,而是一个业务建模问题。技术层面无非是CRUD、鉴权、联调,真正的功夫在于你有没有想清楚客户、跟进、归属、公海池之间是什么关系。把这个逻辑想透了,前端页面和后端接口的代码都能顺畅地长出来,而不是东拼西凑。
如果时间和精力允许,我建议在这个基础上加一个统计看板:客户状态分布饼图、每日新增客户折线图、销售业绩排行。用ECharts实现,数据接口从现有表里聚合就行。这个功能对管理系统的完整度提升非常明显,而且面试或答辩的时候,图表永远比表格更有说服力。做项目这件事,先把核心闭环走通,再谈锦上添花。