简介:这份CRM客户关系管理系统原形面向需要了解企业级客户管理前端实现的学生与前端开发者,围绕销售、市场营销和服务场景,提供一套可用于毕业项目或课程实践的交互页面原型。内容重点涉及JavaScript表单验证、jQuery页面交互与Ajax无刷新数据更新,并强调导航、布局、样式一致性和响应式设计等界面组织方式,适合用来观察CRM前端如何兼顾数据质量与操作体验。压缩包为rar格式,整体约10MB,文件数量与类型明细上游暂未提供,需以下载后的实际目录为准。目前已有301人学习或下载,具备一定参考热度。读者可从中梳理客户信息录入校验、页面元素切换、异步数据获取等实现思路,也能借鉴原型在信息层级与交互流程上的安排,为搭建用户友好、效率较高的CRM前端界面提供参考。
1. 客户跟丢不是销售的问题:CRM 客户关系管理原形的页面切口
销售说客户跟丢了,十有八九不是跟进不勤,而是 CRM 客户关系管理原形页面把关键信息藏得太深。这套 CRM 客户关系管理原形围绕客户列表、客户详情、联系人、跟进记录和商机阶段展开,先把页面原型跑通,再谈后端、权限和报表。它适合产品经理拿去对齐字段,也适合前端拿来做交互底稿,更适合实施同学在客户现场快速演示一条线索怎么变成回款。拿到包后先别改视觉,先把数据契约和状态机对一遍,后面接真实接口时能少返工一轮。
2. 页面骨架先立住:客户列表、详情、跟进、商机四块怎么摆
2.1 先画信息架构:四张主表与三个关系
CRM 原形页面最怕一上来铺仪表盘。仪表盘好看,但评审时没人能说清“客户从哪来、下一步点哪”。我一般先把原形压到四张主表:客户、联系人、跟进记录、商机。客户是主实体,联系人是客户下的对接人,跟进记录是时间线上的动作,商机是可能产生金额的机会。四张表的关系不复杂,但原型阶段必须定死,否则列表页和详情页会对不上号。
| 主表 | 关键字段 | 与客户的关系 | 原型阶段的作用 |
|---|---|---|---|
| 客户 | 客户ID、客户名称、行业、等级、负责人、最近跟进时间 | 主表 | 列表页、详情页的主数据 |
| 联系人 | 联系人ID、客户ID、姓名、职务、电话、是否主联系人 | 一个客户对多个联系人 | 详情页联系人 Tab |
| 跟进记录 | 跟进ID、客户ID、跟进方式、跟进内容、下次跟进时间 | 一个客户对多条记录 | 详情页时间线、列表页最近跟进 |
| 商机 | 商机ID、客户ID、商机名称、金额、阶段、预计成交日期 | 一个客户对多个商机 | 商机 Tab、阶段看板 |
原型阶段不建议把“合同”“回款”“产品明细”全塞进来。页面一多,评审重点就散了。先把客户列表、客户详情、新增跟进、商机阶段这四个动作走通,再按需加 Tab。字段命名也要统一:客户ID用字符串,金额用数值,时间用 ISO 字符串,空值统一用null,不要一会儿空字符串一会儿undefined。这些约定写在原形的 mock 数据里,比写在文档里管用。
提示:先把字段命名统一,再谈页面美化。原型阶段改字段名的成本最低,后面接真实接口时能省一轮映射。
2.2 列表页:筛选条、表格、分页三段的 HTML 骨架
客户列表是 CRM 原形页面的门面,也是演示时被点得最多的页面。结构上别做花,分成筛选条、表格区、分页条三段就够了。筛选条放关键词、负责人、客户等级、商机阶段、最近跟进时间;表格区放客户名称、行业、等级、负责人、最近跟进、商机金额、操作;分页条放总数、页码、每页条数。下面这段 HTML 是骨架,先把语义和挂载点留出来,样式和渲染逻辑后面再补。
<!-- 客户列表页骨架:筛选、表格、分页三段式 --> <section class="crm-page">/* 客户详情页布局:顶部摘要 + 状态条 + Tab 内容 */ .customer-detail { display: grid; grid-template-rows: auto auto 1fr; gap: 12px; } .detail-summary { display: flex; justify-content: space-between; align-items: center; padding: 16px; border: 1px solid #e5e7eb; border-radius: 8px; } .detail-summary .amount { font-size: 20px; font-weight: 600; } .status-bar { display: flex; gap: 8px; padding: 8px 12px; background: #f8fafc; border-left: 4px solid #3b82f6; } .tab-nav { display: flex; gap: 4px; border-bottom: 1px solid #e5e7eb; } .tab-nav button[aria-selected="true"] { border-bottom: 2px solid #3b82f6; font-weight: 600; } .timeline-item { display: grid; grid-template-columns: 120px 1fr; gap: 12px; padding: 10px 0; border-bottom: 1px dashed #e5e7eb; }这段 CSS 的参数重点在grid-template-columns: 120px 1fr,左侧放跟进时间,右侧放跟进内容和下次动作,时间线不会因为内容长短跳来跳去。状态条用左边框而不是整块高亮,避免和客户等级色冲突。Tab 用aria-selected标记选中态,后面接真实组件时也容易迁移。详情页的摘要区不要放超过 6 个字段,放多了就变成第二张表格,演示时没人看得过来。
3. 让原形动起来:mock 数据契约、列表渲染与表单校验
3.1 mock 数据契约:字段名、类型、空值约定
CRM 原形页面能不能顺利演示,七成看 mock 数据。数据不是随便编几条客户名就行,字段名、类型、空值约定要先定。客户ID用字符串,金额用数值,时间用 ISO 字符串,布尔字段用true/false,空值统一null。下面这份 JSON 是三条模拟客户数据,覆盖 A/B/C 等级、不同负责人、不同商机阶段,方便演示筛选和空态。
{ "customers": [ { "customerId": "C-1001", "customerName": "模拟客户A", "industry": "制造业", "level": "A", "owner": "A同学", "lastFollowAt": "2025-03-18T09:30:00.000Z", "nextFollowAt": "2025-03-25T02:00:00.000Z", "status": "跟进中", "contacts": [ { "contactId": "P-01", "name": "联系人甲", "title": "采购经理", "isPrimary": true } ], "opportunities": [ { "opportunityId": "O-01", "name": "一期采购", "amount": 120000, "stage": "已报价" } ], "followUps": [ { "followUpId": "F-01", "type": "电话", "content": "确认预算范围", "createdAt": "2025-03-18T09:30:00.000Z" } ] }, { "customerId": "C-1002", "customerName": "模拟客户B", "industry": "服务业", "level": "B", "owner": "B同学", "lastFollowAt": "2025-03-12T06:10:00.000Z", "nextFollowAt": null, "status": "待跟进", "contacts": [], "opportunities": [], "followUps": [] }, { "customerId": "C-1003", "customerName": "模拟客户C", "industry": "零售业", "level": "C", "owner": "A同学", "lastFollowAt": null, "nextFollowAt": null, "status": "已搁置", "contacts": [], "opportunities": [], "followUps": [] } ] }这份数据的参数说明要落到使用场景:customerId用字符串是为了避免数字ID在 URL 和 localStorage 里被隐式转换;amount用数值是为了后面求和不用parseFloat;lastFollowAt和nextFollowAt用 ISO 字符串,展示时再本地化;status只放客户状态,商机阶段放在opportunities[].stage,两者不要混。contacts、opportunities、followUps允许空数组,用来测试详情页空态。新手常犯的错是把空数组写成null,渲染时就要多写一层判断,原形阶段没必要给自己加戏。
3.2 列表渲染与搜索:从 JSON 到 DOM 的 40 行
列表渲染不要一上来引框架。原形阶段用原生 JS 把筛选、分页、渲染串起来,评审时改起来快,别人接手也看得懂。核心顺序是:先按关键词和筛选条件过滤,再按分页切片,最后渲染表格。页码在筛选条件变化时归 1,这个细节不做,演示时会出现“第二页空白”的尴尬。
// 客户列表渲染:筛选 -> 分页 -> DOM const state = { customers: [], // 原始 mock 数据 page: 1, // 当前页码,从 1 开始 pageSize: 10, // 每页条数 filters: { keyword: "", owner: "", level: "", stage: "" } }; function filterCustomers(list, filters) { return list.filter((c) => { const kw = filters.keyword.trim().toLowerCase(); const hitKeyword = !kw || c.customerName.toLowerCase().includes(kw) || (c.contacts || []).some((p) => p.name.toLowerCase().includes(kw)); const hitOwner = !filters.owner || c.owner === filters.owner; const hitLevel = !filters.level || c.level === filters.level; const hitStage = !filters.stage || (c.opportunities || []).some((o) => o.stage === filters.stage); return hitKeyword && hitOwner && hitLevel && hitStage; }); } function renderList() { const filtered = filterCustomers(state.customers, state.filters); const total = filtered.length; const maxPage = Math.max(1, Math.ceil(total / state.pageSize)); if (state.page > maxPage) state.page = maxPage; // 防止筛选后页码越界 const start = (state.page - 1) * state.pageSize; const rows = filtered.slice(start, start + state.pageSize); document.querySelector("#customerTbody").innerHTML = rows.map((c) => { const amount = (c.opportunities || []).reduce((sum, o) => sum + Number(o.amount || 0), 0); return `<tr> <td><a href="#/customer/${c.customerId}">${c.customerName}</a></td> <td>${c.industry || "-"}</td> <td>${c.level}</td> <td>${c.owner}</td> <td>${c.lastFollowAt ? new Date(c.lastFollowAt).toLocaleDateString() : "未跟进"}</td> <td>${amount.toLocaleString()}</td> <td><button>// 新增跟进:校验必填、长度和时间窗 function validateFollowUp(form) { const type = form.followType.value; const content = form.followContent.value.trim(); const nextAt = form.nextFollowAt.value; const errors = []; if (!type) errors.push("请选择跟进方式"); if (!content) errors.push("跟进内容不能为空"); if (content.length > 200) errors.push("跟进内容不能超过 200 字"); if (nextAt && new Date(nextAt).getTime() < Date.now()) { errors.push("下次跟进时间不能早于当前时间"); } return errors; } function onSubmitFollowUp(form, customer) { const errors = validateFollowUp(form); if (errors.length) { alert(errors.join("\n")); return; } customer.followUps.unshift({ followUpId: `F-${Date.now()}`, type: form.followType.value, content: form.followContent.value.trim(), createdAt: new Date().toISOString() }); customer.lastFollowAt = new Date().toISOString(); customer.nextFollowAt = form.nextFollowAt.value ? new Date(form.nextFollowAt.value).toISOString() : null; customer.status = "跟进中"; }这段校验的参数说明要落到业务边界:followContent去掉首尾空格后再判长度,避免用户用空格绕过必填;nextFollowAt允许为空,为空时写入null,详情页显示“未安排”;createdAt用 ISO 字符串,展示时再转本地时间。followUpId用时间戳只是原形做法,接真实接口时换成后端主键。状态在新增跟进后自动改成“跟进中”,这是演示时的连贯动作,但要注意:如果客户已经“赢单”或“输单”,不要让这条规则覆盖,状态机后面单独处理。
4. 权限与状态流转:原形里最容易被演示漏掉的两条暗线
4.1 角色-页面-按钮三级权限表
CRM 原形页面如果只做一套视图,演示时销售和主管看到的东西一模一样,评审就会问“权限呢”。原型阶段不用接真实登录,但要把角色-页面-按钮三级权限表摆出来。角色先分销售、主管、管理员;页面分客户列表、客户详情、商机看板;按钮分新增、编辑、转移、赢单/输单。权限表用 JSON 配,页面初始化时按当前角色过滤菜单和按钮。
| 角色 | 客户列表 | 客户详情 | 商机看板 | 新增客户 | 转移负责人 | 赢单/输单 |
|---|---|---|---|---|---|---|
| 销售 | 仅本人 | 仅本人 | 仅本人 | 允许 | 不允许 | 允许 |
| 主管 | 本团队 | 本团队 | 本团队 | 允许 | 允许 | 允许 |
| 管理员 | 全部 | 全部 | 全部 | 允许 | 允许 | 允许 |
// 权限配置:页面级 + 按钮级,原型阶段用 JSON 顶住 const permissions = { sales: { pages: ["customer-list", "customer-detail"], buttons: ["customer:add", "opportunity:win", "opportunity:lose"], dataScope: "self" }, manager: { pages: ["customer-list", "customer-detail", "opportunity-board"], buttons: ["customer:add", "customer:edit", "customer:transfer", "opportunity:win", "opportunity:lose"], dataScope: "team" }, admin: { pages: ["customer-list", "customer-detail", "opportunity-board"], buttons: ["*"], dataScope: "all" } }; function canUse(role, type, key) { const conf = permissions[role]; if (!conf) return false; if (type === "page") return conf.pages.includes(key); if (type === "button") return conf.buttons.includes("*") || conf.buttons.includes(key); return false; }这段配置的参数重点是dataScope和按钮 key。dataScope只做原型标记,销售看本人、主管看团队、管理员看全部,列表渲染时按owner过滤;按钮 key 用“实体:动作”命名,避免add这种到处重名的写法。页面初始化时先判断canUse(role, "page", pageKey),不通过就跳回列表;按钮渲染时判断canUse(role, "button", "customer:transfer"),不通过就隐藏或禁用。原型阶段隐藏比禁用更干净,但演示权限时禁用态更能说明“不是没做,是不允许”。
4.2 商机阶段与跟进状态机
CRM 原形里第二容易翻车的是状态。客户状态、跟进状态、商机阶段混在一起,演示时点两下就前后矛盾。我的做法是拆成两条线:客户跟进状态走“待跟进 → 跟进中 → 已搁置”,商机阶段走“新建 → 跟进中 → 已报价 → 赢单/输单”。赢单和输单是终态,不允许再改;已搁置的客户可以重新激活回跟进中。状态机写成对象,别散落在按钮事件里。
// 状态机:客户状态与商机阶段分开,终态不允许回退 const customerStatusFlow = { "待跟进": ["跟进中", "已搁置"], "跟进中": ["已搁置"], "已搁置": ["跟进中"] }; const opportunityStageFlow = { "新建": ["跟进中", "输单"], "跟进中": ["已报价", "输单"], "已报价": ["赢单", "输单"], "赢单": [], "输单": [] }; function canTransit(flow, from, to) { return (flow[from] || []).includes(to); } function transitCustomer(customer, to) { if (!canTransit(customerStatusFlow, customer.status, to)) { throw new Error(`客户状态不能从 ${customer.status} 变为 ${to}`); } customer.status = to; customer.statusHistory = customer.statusHistory || []; customer.statusHistory.push({ from: customer.status, to, at: new Date().toISOString() }); }状态机的参数说明要落到演示路径:客户“待跟进”可以转“跟进中”或“已搁置”,“跟进中”只能转“已搁置”,“已搁置”可以重新激活;商机“已报价”可以转“赢单”或“输单”,终态数组为空,按钮直接不渲染。statusHistory记录变更前后状态和时间,演示时能回答“这个客户什么时候被搁置的”。注意transitCustomer里先判断再赋值,异常抛出后页面要接住并提示,不要让控制台报错吓到评审。
4.3 localStorage 持久化与重置入口
原形页面刷新后数据全丢,演示到一半刷新就尴尬。原型阶段用 localStorage 做持久化最省事,但 key 要带版本号,数据结构一变就换 key,避免旧数据把新页面顶崩。再留一个重置入口,演示前清一次,保证每次从同一份数据开始。
// localStorage 持久化:key 带版本,重置入口单独暴露 const STORAGE_KEY = "crm-prototype-v1"; function saveState(state) { localStorage.setItem(STORAGE_KEY, JSON.stringify({ version: 1, savedAt: new Date().toISOString(), customers: state.customers })); } function loadState() { const raw = localStorage.getItem(STORAGE_KEY); if (!raw) return null; try { const data = JSON.parse(raw); return data.version === 1 ? data : null; } catch (err) { console.warn("mock 数据解析失败,回退到初始数据", err); return null; } } function resetState() { localStorage.removeItem(STORAGE_KEY); location.reload(); }这段持久化的参数重点是STORAGE_KEY的版本号和savedAt。版本号跟着数据结构走,字段增删就升到v2,旧 key 不读,避免兼容判断堆成山;savedAt只做调试标记,不参与业务;loadState解析失败时回退初始数据,不要让页面白屏。重置入口放在页面右上角或调试面板里,演示前点一次,比手动清浏览器缓存可靠。注意 localStorage 只适合原形,真实项目里客户数据不能这么放。
5. 避坑与排查:CRM 原形交付前必须走一遍的 5 个现场
5.1 五个高频现场:现象、原因与解决
第一个现场是筛选后分页错乱。现象是点第二页出现空白,或者同一条客户在第二页又出现一次。原因通常是先分页后筛选,页码没有在筛选条件变化时归 1,切片起点算错。解决是把顺序固定成“先过滤、再算总数、再切片”,筛选表单提交时把state.page重置为 1,渲染前用maxPage兜底。这个坑在演示时很显眼,评审一按筛选再翻页就露馅。
第二个现场是详情页刷新后空白。现象是从列表点进详情正常,刷新浏览器就只剩壳。原因多半是路由参数和 mock 查询的ID类型不一致,列表里是字符串C-1001,查询时用了数字,或者大小写被转掉。解决是客户ID全链路统一字符串,查询前trim,查不到时渲染空态并给“返回列表”按钮,不要留白屏。原型阶段空态比报错友好,评审也能接受“数据不存在”的提示。
第三个现场是销售演示时按钮全亮。现象是销售角色能看到“转移负责人”和“赢单/输单”,点了还能改。原因是权限只控了菜单,没控按钮,或者按钮权限 key 写成了customer:transfer,配置里却是transfer。解决是按“实体:动作”统一 key,渲染按钮前调canUse,不通过就隐藏;演示主管视角时再切角色,让差异一眼可见。权限表要跟页面一起评审,别等开发阶段才补。
第四个现场是跟进记录时间对不上。现象是刚新增的跟进显示成前一天,或者“下次跟进时间”比当前时间还早却保存成功。原因是本地时间字符串和 ISO 字符串混用,时区偏移没处理,时间窗校验只比较了日期没比较时间。解决是存储统一 ISO UTC,展示用toLocaleString,校验用new Date(value).getTime()跟Date.now()比。日期输入框给min属性只是辅助,提交时仍要再校验一次。
第五个现场是商机金额汇总和列表对不上。现象是详情页商机总额比列表页多,或者出现120000120000这种字符串拼接。原因是金额字段有的是字符串,reduce时直接相加;输单商机没有排除;多个商机阶段重复统计。解决是金额入库前Number转换,汇总时按业务规则过滤,比如只统计“已报价、赢单”,输单不计入预测金额。原型阶段把规则写在代码注释里,评审时直接指着说“这里只算赢单和已报价”。
5.2 排查顺序:从数据到渲染再到权限
原形出问题,别先改样式。我一般按“数据 → 渲染 → 权限 → 缓存”四步走。先看 mock 数据有没有重复主键、空字段、类型不一致;再看列表渲染的筛选和分页顺序;然后看当前角色有没有页面和按钮权限;最后清 localStorage 重跑。这个顺序能挡掉八成“看起来是页面问题,实际是数据问题”的现场。下面这段 Node 脚本放在原形包根目录,跑一次就能把重复ID、空名称、金额类型不对的客户揪出来。
// check-mock.js:交付前检查 mock 数据的主键、必填和金额类型 const fs = require("fs"); const data = JSON.parse(fs.readFileSync("./mock/customers.json", "utf8")); const customers = data.customers || []; const ids = new Set(); const errors = []; customers.forEach((c, index) => { if (!c.customerId) errors.push(`第 ${index + 1} 条缺少 customerId`); if (ids.has(c.customerId)) errors.push(`customerId 重复:${c.customerId}`); ids.add(c.customerId); if (!c.customerName) errors.push(`${c.customerId} 缺少 customerName`); (c.opportunities || []).forEach((o) => { if (typeof o.amount !== "number" || Number.isNaN(o.amount)) { errors.push(`${c.customerId} 的商机 ${o.opportunityId} 金额不是数值`); } }); }); if (errors.length) { console.error(errors.join("\n")); process.exit(1); } console.log(`检查通过:${customers.length} 条客户数据`);这段脚本的参数说明很直接:customerId必填且唯一,customerName必填,商机amount必须是数值。process.exit(1)让它在持续集成里能拦住有问题的数据,本地跑也能一眼看到错误行。脚本路径按原形包里的 mock 文件位置改,别硬编码到个人目录。检查通过后再开浏览器演示,比在页面上一个个点快得多。
注意:排查时先清缓存再复现。localStorage 里的旧数据会让“已经修好的 bug”继续出现,浪费一轮排查时间。
6. 从能点到能评审:把 CRM 原形压成一套可验证的演示脚本
原形能点不等于能评审。评审要的是三条路径走完,别人能判断字段够不够、流程顺不顺、权限对不对。我一般准备一套 8 分钟演示脚本:第一条走“新增客户 → 新增联系人 → 新增跟进 → 列表最近跟进更新”;第二条走“商机从新建到已报价再到赢单,列表金额汇总变化”;第三条切主管角色,演示转移负责人和团队数据范围。每条路径都从重置后的固定数据开始,避免上一轮演示留下的状态干扰。
| 演示步骤 | 操作 | 预期结果 | 观察点 |
|---|---|---|---|
| 1 | 重置数据,销售角色进入客户列表 | 列表显示 3 条模拟客户,分页 1/1 | 空态、总数、负责人筛选 |
| 2 | 关键词搜“模拟客户A”,新增一条跟进 | 最近跟进时间更新,状态变“跟进中” | 时间格式、状态条 |
| 3 | 进入商机 Tab,把“已报价”改为“赢单” | 列表金额汇总增加,终态按钮消失 | 状态机、金额类型 |
| 4 | 切主管角色,转移负责人 | 销售视角看不到该客户,主管能看到 | 按钮权限、数据范围 |
| 5 | 刷新页面,再重置数据 | 数据回到初始 3 条 | localStorage 版本与重置入口 |
验证方法不用复杂,重点是每次改完原形都跑一遍固定脚本。我习惯在另一个浏览器无痕窗口开一份,专门看“第一次进入”的空态和默认角色;主窗口留着改代码。演示前清一次 localStorage,确认重置按钮可用,再把三条路径走一遍。固定数据里故意留一个“未跟进”客户和一个“输单”商机,评审时能直接问“这种客户在列表怎么显示”,比口头解释有效。
从那以后我每次交付 CRM 原形前,都强制自己用无痕窗口从重置开始走一遍三条路径,先看数据再看页面,最后切角色。希望帮到你。
本文还有配套的精品资源,点击获取