Node.js+Vue实现CRM客户关系管理系统:从设计到部署全解析

发布时间:2026/9/30 3:54:20
Node.js+Vue实现CRM客户关系管理系统:从设计到部署全解析 客户关系管理系统CRM这个题目在毕业设计和中小型项目里出现频率相当高。后台管理系统列表页表单页时间线的组合方式业务边界清楚既覆盖了常规的后端CRUD能力又包含权限、数据隔离这些稍微进阶的设计点。我目前做过的几套小系统中CRM算是比较有代表性的一个技术栈用的Node.js Vue前后端分离开发。这篇文章把从业务设计到接口实现、再到前端页面的完整过程拆开讲一遍重点回答新手最容易卡壳的地方包括环境配置、npm脚本权限、跨域联调这些绕不开的坑。适合准备做毕设的同学也适合想用Node.js Vue快速落地一个小型管理系统的开发者参考。1. 动工前的业务边界CRM到底管哪些数据1.1 核心业务闭环客户从哪里来、由谁跟、多久不跟会怎样我见过很多同学拿到CRM题目之后第一件事就是去画页面草图这是顺序反了。设计一个客户关系管理系统最先要回答的是数据怎么流转而不是页面长什么样。我会用三个问题来校准自己对业务的理解第一个问题客户从哪里进来可能是地推扫码、官网留资、朋友转介绍也可能是销售自己录入。落到系统里这些入口对应的就是一个来源字段比如枚举值设计成线上搜索、广告投放、老客转介、线下活动、手工录入。第二个问题客户由谁跟进这就是经典的归属概念。每个客户应该有一个负责人owner负责人登录系统之后看到的默认列表应该是我名下的客户而不是全量客户。第三个问题一个客户太久没人跟进会怎样真实业务里这是很常见的场景销售离职了、客户被遗忘了客户资源就这么躺在某个人的名下别人看不到也没法去跟。所以就有了公海池的概念——超过一定时间没有跟进记录的客户系统自动把他释放回公共池子所有销售都能查看和认领。这三个问题捋顺之后系统的功能边界基本就固定了客户管理、跟进记录、公海池流转、用户权限。商机、合同、回款这些属于扩展模块安排在第二期再上也不迟。这种先做最小闭环的思路对学习型项目来说非常重要因为评审看的不是你功能多而是流程有没有走通、设计合不合理。1.2 四张核心表字段怎么定、为什么要这么定数据模型是CRM系统的地基。第一版我建议只做四张表用户表users、客户表customers、跟进记录表follow_records以及一个可选的商机表businesses。表名关键字段说明usersid, username, password, real_name, role, created_atrole区分管理员和销售员password存bcrypt加密后的hashcustomersid, name, phone, industry, source, status, owner_id, remark, created_at, updated_atowner_id为空表示客户在公海池follow_recordsid, customer_id, user_id, content, next_time, created_atnext_time记录下次跟进时间是公海池判断的辅助字段businessesid, 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前后端都是JavaScriptTypeScript也一样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是ORMmysql2是连接MySQL的驱动Sequelize依赖它jsonwebtoken负责签发和验证JWTbcryptjs做密码加密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 vitelatest crm-web -- --template vue cd crm-web npm install npm install vue-router4 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创建销售员账号adminGET/api/customers客户列表支持关键词搜索、状态筛选、分页登录用户按角色隔离数据POST/api/customers新增客户登录用户GET/api/customers/:id客户详情归属人/adminPUT/api/customers/:id更新客户归属人/adminDELETE/api/customers/:id删除客户归属人/adminGET/api/customers/:id/follows某客户的跟进记录列表归属人/adminPOST/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.htmlserver { 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实现数据接口从现有表里聚合就行。这个功能对管理系统的完整度提升非常明显而且面试或答辩的时候图表永远比表格更有说服力。做项目这件事先把核心闭环走通再谈锦上添花。