资讯详情

资讯详情

CRM客户关系管理原型:数据契约、权限与状态机演示设计

简介这份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 classcrm-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-selectedtrue] { 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用数值是为了后面求和不用parseFloatlastFollowAt和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 tda 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 tdbutton>// 新增跟进校验必填、长度和时间窗 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 原形前都强制自己用无痕窗口从重置开始走一遍三条路径先看数据再看页面最后切角色。希望帮到你。本文还有配套的精品资源点击获取
觉得有用,分享给同行:

为您的企业打造数字门面

稳重轻奢商务风格,端正雅致视觉,长效耐看不易过时。

立即咨询 →